SN-2026-020: Single-Pool S3 Bulk Delete Cross-Bucket Path Traversal
Status: Fixed on main (a18293d0c); scheduled for the post-20261109 Server hotfix release
Identifier: SN-2026-020 (Security Advisory Ledger)
Severity: Critical
Affected scope: Single-pool SILO Server deployments (RELEASE.2026-11-09T00-00-00Z and earlier) and inherited upstream MinIO releases; multi-pool deployments (2+ pools) are unaffected on SILO 20261026+
Unaffected components: silo-console, mcli, silo-pkg, and kes
On single-pool deployments, (*erasureServerPools).DeleteObjects (cmd/erasure-server-pool.go) validated every key in an S3 multi-object delete request (POST /<bucket>?delete) with checkDelObjArgs and stored ObjectNameInvalid in derrs[i], but still dispatched the unfiltered slice to the storage pool and overwrote derrs[i] = nil whenever a drive reported the target as found. Because local xlStorage drives join the object key to the bucket directory via pathJoin, keys containing .. segments resolved outside the request bucket into sibling buckets or .minio.sys.
Summary
- Root cause:
checkDelObjArgsrejected../B/secretat entry (derrs[i] = ObjectNameInvalid), but neither the single-node bulk-lock path nor the distributed single-pool per-set path (deleteObjectsInSets) skipped entries with non-nilderrs[i], and the result-merge loop resetderrs[i] = nilon disk success. - Impact: A principal holding
s3:DeleteObjectVersionon an unlocked bucketAcould permanently delete object versions in another bucketB, bypassingCOMPLIANCEObject Lock retention and legal hold. If bucketAhad versioning enabled, a principal holding onlys3:DeleteObjectcould write delete markers onto objects in bucketBor onto.minio.sys/buckets/B/.metadata.bin. - Provenance: Inherited from upstream MinIO’s 2020 single-zone bulk-delete shortcut (
bdd094bc3) and 2025 pool-dispatch rewrite (079d64c80). Multi-pool SILO deployments (2+pools) were already protected by theif derrs[i] != nil { continue }guard added in3c82748e1(Server 20261026). - Fix: Commit
a18293d0cpartitions out entries with non-nilderrs[i]immediately after thecheckDelObjArgsloop—before namespace locking, metadata lookup, or drive dispatch. Requests whose keys are all valid do not enter the new branch.
1. Discovery
On October 31, 2026, Denis Babchuk (@botdeniska) privately reported an Object Lock flaw in DeleteMultipleObjectsHandler (cmd/bucket-handlers.go), showing that S3 DeleteObjects decided whether to enforce version retention and legal hold from the cached hasLockEnabled state of the request bucket rather than verifying the target version under the storage write lock. In SILO Server 20261109, PR #330 (6ef3a52fc) routed physical version deletes in Object Lock buckets through the single-object deletion path under the mutation lock.
During post-release verification of 6ef3a52fc and the private advisory queue, we audited what happens when hasLockEnabled is evaluated on an unlocked bucket A named in the request URL (POST /A?delete) while an <Object><Key> in the XML body uses ../B/secret to address a locked bucket B. Testing against Server 20261026 and Server 20261109 confirmed that single-pool deployments executed the cross-bucket deletion, and subsequent analysis identified two further variants—cross-bucket delete-marker creation via s3:DeleteObject and .minio.sys metadata hiding—along with a self-deadlock when a batch contains multiple traversal aliases for the same target. We thank @botdeniska for the original DeleteObjects Object Lock report that prompted this audit.
2. Provenance and Root Cause
Request Routing and Handler Authorization
On single-object S3 operations (GET /A/key, PUT /A/key, DELETE /A/key), the object key is part of the HTTP request URL, where path normalization and generic HTTP middleware reject .. segments before handler execution.
S3 DeleteObjects (POST /A?delete) instead carries up to 1,000 <Key> elements inside the XML request body:
- URL middleware does not inspect the XML body. Only the bucket name
Aappears in the request URL. - IAM authorization evaluates against bucket
A.DeleteMultipleObjectsHandlerconstructsarn:aws:s3:::A/../B/secretand checkss3:DeleteObjectors3:DeleteObjectVersion. Any policy granting delete access onarn:aws:s3:::A/*matches the prefix and authorizes the entry. - Bucket-level Object Lock precheck inspects bucket
A.globalBucketObjectLockSys.Get(bucket)checks whether bucketAhas Object Lock enabled (hasLockEnabled). When bucketAis unlocked, the handler bypasses the per-version lock path introduced in6ef3a52fcand passes the batch toerasureServerPools.DeleteObjects.
Object-Layer Error Erasure on Single-Pool Paths
At the top of (*erasureServerPools).DeleteObjects (cmd/erasure-server-pool.go), the entry loop validates every object name after directory-object encoding:
For ../B/secret, checkDelObjArgs (via hasBadPathComponent) returns ObjectNameInvalid and records it in derrs[i]. On a single-pool deployment, three subsequent steps nullified that check:
- Unfiltered dispatch: Neither the single-node bulk-lock branch nor the distributed single-pool per-set branch (
deleteObjectsInSets) skipped entries wherederrs[i] != nil. The fullobjectsslice was passed to the pool. - Path joining on local drives: Local
xlStoragedrives store each bucket as a subdirectory under the drive root (/data/A,/data/B,/data/.minio.sys). InsidexlStorage.deleteVersions,pathJoin(volumeDir, path)runspath.Cleanon/data/A+../B/secret, resolving to/data/B/secretand removing the version or writing a delete marker inxl.meta. - Error reset during result merge: When merging per-pool results,
erasureServerPools.DeleteObjectsclearedderrs[i]whenever a pool reported the entry found without a drive error:
Because /data/B/secret existed on disk and the local drive completed the operation (found == true, err == nil), derrs[i] = nil overwrote the initial ObjectNameInvalid error and returned <Deleted> with HTTP 200 OK. In deleteObjectsInSets, per-set drive results similarly overwrote derrs[idx] directly.
Upstream Git History
Tracing cmd/xl-zones.go, cmd/erasure-server-sets.go, and cmd/erasure-server-pool.go shows how this behavior evolved in upstream minio/minio:
| Date | Commit | Origin | Change |
|---|---|---|---|
| 2020-02 | ab7d3cd50 (minio#8974) |
Upstream MinIO | Replaced per-object lock loops (which skipped derrs[i] != nil) with a bulk lock and passed the unfiltered objects slice to zones |
| 2020-11 | bdd094bc3 (minio#10994) |
Upstream MinIO | Added if z.SingleZone() { return z.serverSets[0].DeleteObjects(...) }, discarding derrs entirely on single-pool deployments |
| 2025-01 | 079d64c80 (minio#20821) |
Upstream MinIO | Unified pool dispatch and set derrs[i] = nil in the merge loop whenever any pool reported found && err == nil |
| 2026-10 | 3c82748e1 |
PGSTY SILO (20261026) |
Added if derrs[i] != nil { continue } inside the !z.SinglePool() multi-pool branch, protecting 2+-pool deployments while leaving single-pool paths unchanged |
3. Trigger Conditions
Exploitation requires all three conditions:
- Single-pool topology: The deployment has a single server pool (
z.SinglePool() == true), including single-node single-drive, single-node multi-drive erasure, and distributed multi-node clusters with one pool. Multi-pool deployments (2+server pools) onServer 20261026+ skip entries withderrs[i] != niland are not affected. - Delete permission on at least one bucket: The caller holds credentials (or a presigned
POST /<bucket>?deleteURL) granteds3:DeleteObjectors3:DeleteObjectVersionon any bucketA. - Relative
..path segment in the XML body: TheDeleteObjectsXML body contains a<Key>that retains a..segment afterencodeDirObject(for example,../B/secretora/../../.minio.sys/buckets/B/.metadata.bin).
4. Impact Scope
Reproduction against single-node single-drive and single-node 16-drive erasure instances using a principal restricted to arn:aws:s3:::A/* confirmed four behaviors:
Request Bucket A |
Permission on A |
XML Request Entry | Observed Result |
|---|---|---|---|
| Unlocked (versioned or unversioned) | s3:DeleteObjectVersion |
<Key>../B/secret</Key> + <VersionId>v1</VersionId> (B under COMPLIANCE lock) |
Version v1 in bucket B is permanently deleted from disk despite active COMPLIANCE retention |
| Versioning enabled | s3:DeleteObject (no VersionId) |
<Key>../B/doc</Key> (no <VersionId>) |
A delete marker is appended to B/doc; subsequent GET/HEAD requests return 404 Object not found |
| Versioning enabled | s3:DeleteObject (no VersionId) |
<Key>../.minio.sys/buckets/B/.metadata.bin</Key> |
A delete marker is appended to bucket B’s metadata object in .minio.sys (IAM records and server configuration share the same .minio.sys storage mechanism) |
| Versioning disabled | s3:DeleteObject |
<Key>../B/small</Key> (single-version inline object) |
Response reports Deleted, but xlStorage.deleteFile’s strings.HasPrefix base-path check returns early before unlinking xl.meta (unless B starts with A as a string prefix or the version has an external dataDir) |
Operational Considerations
- Delete-marker creation requires neither
s3:DeleteObjectVersionnor aversionId:DeleteMultipleObjectsHandlersetsopts.Versionedfrom the versioning configuration of request bucketA. WhenAis versioned and<VersionId>is omitted,xlStorage.deleteVersionsappends a delete marker to the target’sxl.metaviaWriteAllwithout enteringdeleteFile. - Single-node vs. distributed single-pool deployments:
- Single-node: All drives are local
xlStorageinstances without internode RPC guards, so a single request reaches write quorum and completes immediately. - Distributed single-pool: Remote drives are wrapped by
guardedStorage(SN-2026-002,cmd/storage-rest-server-guard.go), which rejects..paths witherrInvalidArgument. A single request therefore fails write quorum on remote drives, but still executes on every local drive attached to the handling node. Repeating the request across enough nodes can corrupt local shards on each node until damaged drives exceed erasure parity.
- Single-node: All drives are local
- Cross-bucket lock acquisition and self-deadlock:
NewNSLocknormalizes paths before acquiring namespace locks, so../B/secretacquires the write lock on bucketB’s object. If a single request contains two distinct traversal aliases that normalize to the same target (such as../B/secretandx/../../B/secret), the single-node bulk lock—which locks keys sequentially while holding earlier locks—blocks waiting for the lock it already holds untilglobalOperationTimeoutexpires.
5. Fix: Sufficiency and Necessity
Commit a18293d0c adds a 22-line filter immediately after the checkDelObjArgs loop in (*erasureServerPools).DeleteObjects (cmd/erasure-server-pool.go):
Why the Fix Is Necessary and Sufficient
- Single chokepoint before locking and storage dispatch: Filtering immediately after
checkDelObjArgsprecedesdeleteObjectsInSets,multiDeleteLock.GetLock, metadata lookups, and pool fan-out. It covers single-node bulk locks, distributed single-pool per-set locks, and multi-pool deployments in one place across allObjectLayer.DeleteObjectscallers. - Prevents cross-bucket locking and alias self-deadlocks: Entries that fail
checkDelObjArgsreturn beforeNewNSLockis called, so invalid keys never lock objects in other buckets or deadlock a batch against itself. - Consistent path validation predicate:
checkDelObjArgsuseshasBadPathComponent, matchingDeleteObject,PutObject,GetObject, HTTP query guards, andSN-2026-002’s internodeguardedStorage. OncehasBadPathComponentpasses,pathJoin(path.Clean) folds leading slashes under<drive>/<bucket>/and has no..segments to escape<drive>/<bucket>/. Code review across lifecycle expiry, batch jobs, replication, healing, anddeleteObjectsReconciledconfirmed no other client-controlled delete path bypassescheck*Args. - Bounded recursion and index preservation: Because
encodeDirObjectis idempotent and every entry invalidhas already passedcheckDelObjArgs, the recursivez.DeleteObjectscall never re-enters the error branch, processes valid sibling entries (includingdir/directory objects) normally, and maps results back to their original indices viaat.
6. Design Trade-Offs
| Option | Benefit | Cost / Risk | Decision |
|---|---|---|---|
Filter after checkDelObjArgs in erasureServerPools.DeleteObjects (a18293d0c) |
Covers all topologies, lock paths, and callers in 22 lines; zero allocations when all keys are valid | Invalid keys on single-pool deployments now return XMinioInvalidObjectName instead of Deleted |
Adopted |
Add checkObjectArgs in DeleteMultipleObjectsHandler |
Rejects invalid keys in the HTTP handler | Redundant once the object layer refuses them; checkObjectArgs rejects trailing slashes and breaks valid dir/ batch deletes |
Rejected |
Add path-containment guards to local xlStorage methods in this hotfix |
Adds storage-layer defense in depth | Guarding only two delete methods is asymmetric across StorageAPI, while wrapping all local calls touches hot erasure I/O paths and widens the regression surface |
Tracked separately |
Implementation Notes and Behavioral Changes
- Directory objects (
dir/and../):encodeDirObjectruns beforecheckDelObjArgsand rewrites trailing slashes into__XLDIR__(dir/becomesdir__XLDIR__, and../becomes..__XLDIR__).dir/continues to delete directory objects normally, while..__XLDIR__contains no..path segment and resolves to a literal name inside the request bucket without traversing upward. - Zero fast-path overhead: When all keys in a request are valid,
slices.ContainsFuncscansderrswithout allocating or branching. - Windows path normalization (
.. .): During code review, we evaluated whether an intermediate segment such as.. .on Windows could passhasBadPathComponentand normalize to... Under Microsoft’s File path formats on Windows systems rules, Win32 evaluates.and..segments before stripping a single trailing period from intermediate segments, does not strip trailing spaces from intermediate segments, and does not re-evaluate..after trimming, leaving..as a literal directory name inside the bucket. BecausehasBadPathComponentis shared across all S3 APIs and automated CI runs on Linux, standalone verification on Windows hosts is tracked separately from this hotfix. - API response change: On single-pool deployments,
DeleteObjectsentries whose post-directory-encoding name failscheckDelObjArgsnow return per-entryXMinioInvalidObjectNamein<Error>rather than<Deleted>, matching single-objectDELETEand multi-pool deployments.
Remediation and Mitigation
- Upgrade SILO Server on single-pool deployments to the post-
20261109hotfix release containinga18293d0c(or build frommainata18293d0cor later). Updatingsilo-consoleormclialone does not remediate the server issue. - Temporary mitigation prior to upgrade: No configuration toggle disables this code path on unpatched single-pool servers. Until upgraded, revoke
s3:DeleteObjectands3:DeleteObjectVersionfrom untrusted principals on single-pool deployments, or isolate untrusted tenants onto separate instances. Multi-pool deployments (2+server pools) on SILO Server 20261026 or 20261109 are unaffected. - Post-upgrade audit: Where untrusted principals held delete permissions on a versioned bucket in an unpatched single-pool deployment, check buckets (
mcli ls --versions) and.minio.sys/buckets/<bucket>/.metadata.binfor unexpected delete markers.
See the Security Advisory Ledger, SN-2026-002 Internode Path Containment, and the Component Version Matrix.