How LazyOwn Attack Surface Visualization Works with Bloodhound Data Integration

LazyOwn transforms raw Bloodhound Active Directory exports into interactive attack surface visualizations by parsing ZIP archives server-side, extracting nodes and privilege relationships, and rendering them through a Vis.js frontend with curated lists of critical attack vectors.

The LazyOwn C2 framework (available at grisuno/lazyown) provides Red Team operators with a streamlined approach to analyzing Active Directory environments through integrated attack surface visualization with Bloodhound data integration. This capability bridges the gap between raw Bloodhound collection data and actionable security insights, converting complex AD privilege relationships into an explorable graph interface.

Bloodhound Data Processing Pipeline

The visualization workflow follows a strict three-stage pipeline implemented in lazyc2.py. First, the /upload_zip endpoint (lines 3985-4040) accepts Bloodhound export archives with validation and security checks. Second, the process_bloodhound_zip function (lines 508-610) parses the JSON collections and constructs graph primitives. Third, extract_attack_vectors analyzes these structures to identify privileged accounts and dangerous permission edges before the frontend renders the interactive network.

Secure File Upload and Validation

The upload handler implements security-hardened file processing to prevent directory traversal and ensure data integrity. Located in lazyc2.py, the upload_zip_file function generates UUID-based temporary filenames to prevent collisions and canonicalizes all paths using os.path.abspath to verify files remain within the configured UPLOAD_FOLDER.

@app.route('/upload_zip', methods=['POST'])
def upload_zip_file():
    """Handles the file upload, processes the BloodHound ZIP, and prepares data for visualization."""
    if 'file' not in request.files:
        return render_template('index.html', error="No file part")
    file = request.files['file']
    if file.filename == '' or not file.filename.lower().endswith('.zip'):
        return render_template('index.html', error="Only ZIP files are allowed")

    os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
    unique_filename = f"{uuid.uuid4().hex}.zip"
    filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)

    # Security check – ensure the resolved path stays inside the upload folder

    if not os.path.abspath(filepath).startswith(os.path.abspath(app.config['UPLOAD_FOLDER'])):
        return render_template('index.html', error="Invalid file path")

    file.save(filepath)
    nodes, edges, error_message, ad_data = process_bloodhound_zip(filepath)
    
    try: os.remove(filepath)
    except Exception: pass

    if error_message:
        return render_template('index.html', error=error_message)

    return render_template('surface.html', nodes=nodes, edges=edges, ad_data=ad_data)

The function strictly validates file extensions, enforces path containment, and guarantees cleanup of temporary ZIP files regardless of parsing success or failure.

Extracting Nodes and Edges from Bloodhound Exports

Parsing ZIP Contents

The process_bloodhound_zip function in lazyc2.py (lines 508-610) orchestrates the extraction of Active Directory objects from Bloodhound's multi-file JSON format. It iterates through every *.json file within the archive—typically including users.json, groups.json, computers.json, and relationship files—building a comprehensive graph representation.

def process_bloodhound_zip(zip_filepath):
    """
    Processes a BloodHound ZIP file to extract nodes and edges for graph visualization.
    Returns: (nodes, edges, error_message, ad_data)
    """
    nodes_data = {}
    edges_data = []
    error_message = None

    try:
        with zipfile.ZipFile(zip_filepath, 'r') as zip_ref:
            json_files = [n for n in zip_ref.namelist() if n.endswith('.json')]
            if not json_files:
                return [], [], "No JSON files found in the ZIP", {}

            for name in json_files:
                with zip_ref.open(name) as f:
                    data = json.load(f)
                    items = data.get('data', []) if isinstance(data, dict) else data
                    for item in items:
                        # Node extraction

                        if 'ObjectIdentifier' in item and 'Properties' in item:
                            node_id = item['ObjectIdentifier']
                            if node_id not in nodes_data:
                                props = item['Properties']
                                nodes_data[node_id] = {
                                    'id': node_id,
                                    'label': props.get('name', node_id),
                                    'title': json.dumps(props, indent=2),
                                    'type': name.split('_')[1].replace('.json', '')
                                }

                        # Edge extraction from ACEs and Group Memberships

                        if 'Aces' in item and isinstance(item['Aces'], list):
                            for ace in item['Aces']:
                                if 'PrincipalSID' in ace and 'RightName' in ace:
                                    edges_data.append({
                                        'from': item['ObjectIdentifier'],
                                        'to': ace['PrincipalSID'],
                                        'label': ace['RightName']
                                    })
                        if 'PrimaryGroupSID' in item and item['PrimaryGroupSID']:
                            edges_data.append({
                                'from': item['ObjectIdentifier'],
                                'to': item['PrimaryGroupSID'],
                                'label': 'MemberOf'
                            })
    except FileNotFoundError:
        error_message = f"File not found: {zip_filepath}"
    except zipfile.BadZipFile:
        error_message = f"Invalid or corrupted ZIP file: {zip_filepath}"
    except Exception:
        error_message = "An unexpected error occurred while processing the ZIP"

    ad_data = extract_attack_vectors(list(nodes_data.values()), edges_data)
    return list(nodes_data.values()), edges_data, error_message, ad_data

Building the Graph Structure

For each Bloodhound object, the parser constructs nodes using the ObjectIdentifier as a unique ID and populates properties including human-readable labels, JSON-formatted tooltips (title), and object types derived from filenames. Edges are constructed from two primary relationship sources:

  • ACE relationships: Access Control Entries defining permissions like GenericAll, WriteDacl, and WriteOwner
  • Group memberships: PrimaryGroupSID relationships indicating MemberOf associations

After building the raw graph, the function invokes extract_attack_vectors to derive high-value security insights.

Identifying Critical Attack Vectors

The extract_attack_vectors function transforms raw graph data into actionable intelligence by identifying privileged accounts and dangerous permissions according to the LazyOwn source code. It searches for high-value AD groups (Domain Admins, Enterprise Admins, Administrators) and flags edges containing high-risk rights defined in the dangerous_rights list.

def extract_attack_vectors(nodes, edges):
    """
    Analyses BloodHound nodes and edges to extract critical attack vectors for AD compromise.
    Returns a dict with privileged_accounts, dangerous_permissions, potential_attack_paths, misconfigurations.
    """
    ad_data = {
        'privileged_accounts': [],
        'dangerous_permissions': [],
        'potential_attack_paths': [],
        'misconfigurations': []
    }

    # Privileged accounts detection

    for node in nodes:
        if node.get('type') in ['User', 'Group'] and node.get('label', '').lower() in [
            'domain admins', 'enterprise admins', 'administrators'
        ]:
            ad_data['privileged_accounts'].append({
                'id': node['id'],
                'label': node['label'],
                'type': node['type'],
                'details': node['title']
            })

    # Dangerous permissions detection

    dangerous_rights = ['GenericAll', 'WriteDacl', 'WriteOwner',
                       'Owns', 'AllExtendedRights', 'DCSync']
    for edge in edges:
        if edge['label'] in dangerous_rights:
            src = next((n for n in nodes if n['id'] == edge['from']), None)
            dst = next((n for n in nodes if n['id'] == edge['to']), None)
            if src and dst:
                ad_data['dangerous_permissions'].append({
                    'from': src['label'],
                    'to': dst['label'],
                    'right': edge['label'],
                    'source_type': src['type'],
                    'target_type': dst['type']
                })

    return ad_data

The function specifically flags GenericAll, WriteDacl, WriteOwner, Owns, AllExtendedRights, and DCSync permissions as critical attack vectors, resolving source and target node labels to provide human-readable context for security operators.

Interactive Frontend Visualization

The Vis.js network library powers the client-side rendering in templates/surface.html and templates/graph.html. The Flask backend injects JSON-encoded node and edge datasets directly into the Jinja2 templates using the tojson filter, eliminating the need for additional API calls.

{% set nodes_json = nodes|tojson %}
{% set edges_json = edges|tojson %}
{% set ad_data_json = ad_data|tojson %}

The JavaScript initialization creates interactive DataSets that support real-time filtering, physics-based layouts, and clustering:

const nodes = new vis.DataSet({{ nodes_json }});
const edges = new vis.DataSet({{ edges_json }});

const container = document.getElementById('mynetwork');
const data = { nodes, edges };
const options = {/* filters, clustering, physics, etc. */};

const network = new vis.Network(container, data, options);

The surface.html template renders an attack vector accordion displaying the four categories from ad_data: privileged accounts, dangerous permissions, potential attack paths, and misconfigurations. UI controls allow operators to filter by node type, edge relationship, and depth, while statistical panels update dynamically as the graph changes.

Practical Implementation Examples

Uploading Bloodhound Data via cURL

Operators can programmatically upload Bloodhound exports using standard HTTP POST requests:

curl -X POST http://localhost:5000/upload_zip \
     -F "file=@/path/to/BloodHound-Data.zip"

Programmatic Parsing in Python

The parsing functions can be imported directly for custom analysis workflows:

from lazyc2 import process_bloodhound_zip

zip_path = "samples/BloodHound-Data.zip"
nodes, edges, err, ad_data = process_bloodhound_zip(zip_path)

if err:
    print(f"Error: {err}")
else:
    print(f"Found {len(nodes)} nodes and {len(edges)} edges")
    print("Privileged accounts:")
    for acct in ad_data['privileged_accounts']:
        print(f" * {acct['label']} ({acct['type']})")

Rendering in Custom Flask Views

The visualization components integrate seamlessly into custom route handlers:

@app.route('/demo')
def demo():
    nodes, edges, _, ad_data = process_bloodhound_zip('sample.zip')
    return render_template('surface.html',
                           nodes=nodes,
                           edges=edges,
                           ad_data=ad_data)

Summary

LazyOwn's attack surface visualization with Bloodhound data integration provides a comprehensive pipeline for Active Directory security analysis:

  • Secure upload handling in lazyc2.py validates ZIP archives and prevents path traversal through UUID-based filenames and path canonicalization
  • Bloodhound JSON parsing extracts nodes from ObjectIdentifier fields and builds relationship edges from ACE and group membership data
  • Attack vector extraction identifies privileged accounts and dangerous permissions including GenericAll, WriteDacl, and DCSync rights
  • Vis.js frontend rendering in templates/surface.html delivers interactive graph exploration with real-time filtering and curated security insights
  • Jinja2 data injection eliminates frontend API dependencies by embedding parsed graph data directly into the rendered HTML

Frequently Asked Questions

How does LazyOwn prevent directory traversal during Bloodhound ZIP uploads?

The upload_zip_file function in lazyc2.py resolves absolute paths using os.path.abspath and validates that the target filepath starts with the absolute path of the configured UPLOAD_FOLDER. This canonicalization check ensures uploaded files cannot escape the designated storage directory regardless of malicious filename inputs.

What specific Bloodhound relationship types does LazyOwn extract for visualization?

According to the source code in lazyc2.py, the parser extracts two primary relationship categories: ACE edges from the Aces array (representing permissions like GenericAll, WriteDacl, and WriteOwner) and group membership edges from PrimaryGroupSID fields (rendered as MemberOf relationships). These cover the critical privilege escalation pathways in Active Directory environments.

Can the attack vector detection logic be extended to identify custom misconfigurations?

Yes. The extract_attack_vectors function structure in lazyc2.py includes placeholder arrays for potential_attack_paths and misconfigurations specifically reserved for custom heuristics. Security teams can extend this function to implement GPO analysis, Kerberoasting detection, or domain-specific privilege anomalies while maintaining the existing visualization pipeline through surface.html.

What frontend technologies render the interactive attack surface graph?

The visualization layer combines Jinja2 templating (for server-side data injection), Vis.js Network (for the interactive graph canvas), and Bootstrap components (for the sidebar controls and attack vector accordion). These are defined in templates/surface.html and templates/graph.html, with the Vis.js DataSets receiving pre-processed node and edge arrays directly from the Flask backend.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →