vulnlab

What it does
Three small Flask apps, each with one classic web bug: SSRF through a redirect, SQL injection in ORDER BY, and a missing permission check. Each has a fix and tests.
Why it matters
The attack that works on the vulnerable app is blocked on the fixed one, and the tests show both.
What was found
All 3 fixes verified. sinkline flagged 2 of the 3 bugs and also flagged both fixed versions of those two, which are false alarms. It reports nothing on the permission check, because that is not a data-flow bug.

See it Read the limits

Each of the three vulnerable services shows its impact in a test, and the same checks fail against its fixed version. sinkline reports two of the three bugs and also both fixed versions of those two (false positives), and nothing on the IDOR, which is not a taint flow. The detection rules alert on the attempts either way.

As left: 3 of 3 fixes verified run 43134837698 passed 2026-11-12

  • vulnerable service shows the impact
  • fixed service blocks the impact

3 vulnerable services show their impact in a test

3 fixed services block it, checked by the same tests

See it

Pick a lab and a version, vulnerable or fixed, and read the code, the tests and the scanner output side by side.

Plate 1

Recorded, not live. All six lab and version pairs are listed here; with JavaScript on, a two-part switch shows one at a time.

Recorded results for each lab and version: its own tests, sinkline, and the detection replay
Lab and versionIts testssinklineDetection replay (same for both versions)
Lab 01
SSRF through a redirect (CWE-918)
Vulnerable
Impact demonstrated

1 of 1 test passed

Finding

Correct: the finding is the bug.

Attack sample: 2 events, 1 alert.

Benign sample: 2 events, 0 alerts.

Lab 01
SSRF through a redirect (CWE-918)
Fixed
Impact blocked

24 of 24 tests passed

Finding

False positive: this version is fixed.

Attack sample: 2 events, 1 alert.

Benign sample: 2 events, 0 alerts.

Lab 02
SQL injection in ORDER BY (CWE-89)
Vulnerable
Impact demonstrated

1 of 1 test passed

Finding

Correct: the finding is the bug.

Attack sample: 4 events, 1 alert.

Benign sample: 4 events, 0 alerts.

Lab 02
SQL injection in ORDER BY (CWE-89)
Fixed
Impact blocked

7 of 7 tests passed

Finding

False positive: this version is fixed.

Attack sample: 4 events, 1 alert.

Benign sample: 4 events, 0 alerts.

Lab 03
IDOR / broken object-level authorization (CWE-639)
Vulnerable
Impact demonstrated

18 of 18 tests passed

No finding

Not reported.

Attack sample: 11 events, 1 alert.

Benign sample: 4 events, 0 alerts.

Lab 03
IDOR / broken object-level authorization (CWE-639)
Fixed
Impact blocked

18 of 18 tests passed

No finding

Nothing to report on the fixed version.

Attack sample: 11 events, 1 alert.

Benign sample: 4 events, 0 alerts.

Lab 01, SSRF through a redirect: code, tests, sinkline and detection for both versions

Lab 01, Vulnerable

Impact demonstrated The preview endpoint returned the internal service's marker, reached through a redirect on an allowed host.

The preview endpoint checks the host of the URL it was given once, then fetches it with a client that follows redirects on its own.

Vulnerable line the write-up names: 23. sinkline path: source line 19, propagate line 19, sink line 23.

from __future__ import annotations

from urllib.parse import urlsplit

import requests
from flask import Flask, jsonify, request


def create_app(settings: dict | None = None) -> Flask:
    config = {"allowed_hosts": {"127.0.0.1"}, **(settings or {})}
    app = Flask(__name__)

    def host_allowed(url: str) -> bool:
        parts = urlsplit(url)
        return parts.scheme in ("http", "https") and parts.hostname in config["allowed_hosts"]

    @app.get("/preview")
    def preview():
        url = request.args.get("url", "")
        if not host_allowed(url):
            return jsonify(error="host not allowed"), 403
        try:
            upstream = requests.get(url, timeout=3)
        except requests.RequestException:
            return jsonify(error="fetch failed"), 502
        return jsonify(status=upstream.status_code, excerpt=upstream.text[:300])

    return app

1 of 1 test cases passed in this version.

Recorded results of the tests that belong to this version
TestWhat it showsResult
test_vulnerable_service_returns_internal_marker_via_allowed_redirectImpact demonstratedPass

Finding SL-SSRF-001, CWE-918, high, app.py:23. Exit status 0.

Correct: the finding is the bug.

[HIGH] SL-SSRF-001 A10:2021 CWE-918  app.py:23:24
  SSRF: tainted URL fetched server-side
  flask.request.args reaches requests.get without a sanitizer for CWE-918.
  ┌ source     app.py:19  url = request.args.get("url", "")  # flask.request.args
  │ propagate  app.py:19  url = request.args.get("url", "")  # .get()
  └ sink       app.py:23  upstream = requests.get(url, timeout=3)  # requests.get
  fix: Allow-list hosts/schemes, resolve DNS and reject private ranges, disable redirects.

1 finding(s) in 1 file(s), 6 function(s) summarized
by OWASP: A10:2021 Server-Side Request Forgery: 1

Lab 01, Fixed

Impact blocked Both the direct and the redirected request were refused and the internal service was never contacted.

The app follows redirects itself, at most three hops, and checks every hop's host and resolved address before connecting to that address.

Lines that are not in the vulnerable version: 3, 4, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 22, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 62, 63, 64, 65, 66. sinkline path: source line 62, propagate line 62, call line 62, sink line 53.

from __future__ import annotations

import socket
from urllib.parse import urljoin, urlsplit

import requests
from flask import Flask, jsonify, request

MAX_HOPS = 3
MAX_URL_LENGTH = 2048


class Blocked(Exception):
    """A well-formed URL that policy refuses (403)."""


class InvalidUrl(Exception):
    """Input that is not a usable http URL at all (400)."""


def create_app(settings: dict | None = None) -> Flask:
    config = {"allowed_hosts": {"127.0.0.1"}, "allowed_addrs": {"127.0.0.1"}, **(settings or {})}
    app = Flask(__name__)

    def resolve_allowed(url: str) -> tuple[str, str]:
        if len(url) > MAX_URL_LENGTH:
            raise InvalidUrl("too long")
        try:
            parts = urlsplit(url)
            hostname = parts.hostname
            port = parts.port
        except ValueError:  # unterminated IPv6 literal, non-numeric or out-of-range port
            raise InvalidUrl("unparseable") from None
        if parts.scheme != "http":
            raise InvalidUrl("only http is supported")
        if not hostname:
            raise InvalidUrl("missing host")
        if "@" in parts.netloc:
            raise InvalidUrl("credentials in the URL are not accepted")
        if port == 0:
            raise InvalidUrl("invalid port")
        if hostname not in config["allowed_hosts"]:
            raise Blocked("host not allowed")
        port = port or 80
        addr = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)[0][4][0]
        if addr not in config["allowed_addrs"]:
            raise Blocked("address not allowed")
        return parts.netloc, parts._replace(netloc=f"{addr}:{port}").geturl()

    def fetch(url: str) -> requests.Response:
        for _ in range(MAX_HOPS + 1):
            host_header, pinned = resolve_allowed(url)
            upstream = requests.get(pinned, headers={"Host": host_header}, allow_redirects=False, timeout=3)
            if not upstream.is_redirect:
                return upstream
            url = urljoin(url, upstream.headers["Location"])
        raise Blocked("too many redirects")

    @app.get("/preview")
    def preview():
        try:
            upstream = fetch(request.args.get("url", ""))
        except InvalidUrl:
            return jsonify(error="invalid url"), 400
        except Blocked as exc:
            return jsonify(error=str(exc)), 403
        except requests.RequestException:
            return jsonify(error="fetch failed"), 502
        return jsonify(status=upstream.status_code, excerpt=upstream.text[:300])

    return app

24 of 24 test cases passed in this version.

Recorded results of the tests that belong to this version
TestWhat it showsResult
test_fixed_service_does_not_return_internal_markerImpact blockedPass
test_fixed_service_still_previews_pages_and_follows_allowed_redirectsFeature still worksPass
test_fixed_service_answers_400_to_every_malformed_urlMalformed input answered with 400Pass (18 of 18 cases)
test_fixed_service_answers_400_to_a_malformed_redirect_targetMalformed input answered with 400Pass (2 of 2 cases)
test_fixed_service_keeps_403_for_well_formed_urls_on_other_hostsWell-formed request for another host still refusedPass
test_address_pinning_refuses_a_host_whose_address_is_not_approvedAddress check enforced (test fails if it is removed)Pass

Finding SL-SSRF-001, CWE-918, high, app.py:62. Exit status 0.

False positive: this version is fixed. False positive. sinkline does not recognize a custom allow-list or address pinning as a sanitizer.

[HIGH] SL-SSRF-001 A10:2021 CWE-918  app.py:62:24
  SSRF: tainted URL fetched server-side
  flask.request.args reaches requests.get (inside fetch, app.py:53) without a sanitizer for CWE-918.
  ┌ source     app.py:62  upstream = fetch(request.args.get("url", ""))  # flask.request.args
  │ propagate  app.py:62  upstream = fetch(request.args.get("url", ""))  # .get()
  │ call       app.py:62  upstream = fetch(request.args.get("url", ""))  # passed as `url` to fetch()
  └ sink       app.py:53  upstream = requests.get(pinned, headers={"Host": host_header}, allow_redirects=False, timeout=3)  # requests.get inside fetch()
  fix: Allow-list hosts/schemes, resolve DNS and reject private ranges, disable redirects.

1 finding(s) in 1 file(s), 8 function(s) summarized
by OWASP: A10:2021 Server-Side Request Forgery: 1

Lab 01, detection replay

Rule: URL preview request names an internal or metadata address. test_detection_rule_fires_on_recorded_attack_not_on_benign_traffic: Pass.

The rule replayed over two recorded access logs
SampleEvents and alertsResult
Attack sample (recorded from the vulnerable service)2 events, 1 alertAlert
Benign sample (recorded from the fixed service)2 events, 0 alertsNo alert
sample_attack.ndjson: 2 events, 1 alert(s)
  ALERT 'URL preview request names an internal or metadata address': 2 matching event(s)
sample_benign.ndjson: 2 events, 0 alert(s)
Lab 02, SQL injection in ORDER BY: code, tests, sinkline and detection for both versions

Lab 02, Vulnerable

Impact demonstrated A sort value made a second table decide the order of the rows that came back.

The search text is bound as a query parameter, but the sort column is pasted into the SQL string.

Vulnerable line the write-up names: 16. sinkline path: source line 13, propagate line 13, propagate line 16, sink line 19.

from __future__ import annotations

import sqlite3

from flask import Flask, jsonify, request


def create_app(settings: dict) -> Flask:
    app = Flask(__name__)

    @app.get("/api/products")
    def products():
        sort = request.args.get("sort", "name")
        direction = "DESC" if request.args.get("order") == "desc" else "ASC"
        needle = f"%{request.args.get('q', '')}%"
        query = f"SELECT id, name, price FROM products WHERE name LIKE ? ORDER BY {sort} {direction}"
        with sqlite3.connect(settings["db_path"]) as conn:
            try:
                rows = conn.execute(query, (needle,)).fetchall()
            except sqlite3.Error:
                return jsonify(error="bad query"), 400
        return jsonify([{"id": i, "name": n, "price": p} for i, n, p in rows])

    return app

1 of 1 test cases passed in this version.

Recorded results of the tests that belong to this version
TestWhat it showsResult
test_vulnerable_sort_parameter_lets_another_table_decide_the_orderImpact demonstratedPass

Finding SL-SQLI-001, CWE-89, critical, app.py:19. Exit status 0.

Correct: the finding is the bug.

[CRITICAL] SL-SQLI-001 A03:2021 CWE-89  app.py:19:24
  SQL built from tainted data
  flask.request.args reaches sqlite3.Connection.execute without a sanitizer for CWE-89.
  ┌ source     app.py:13  sort = request.args.get("sort", "name")  # flask.request.args
  │ propagate  app.py:13  sort = request.args.get("sort", "name")  # .get()
  │ propagate  app.py:16  query = f"SELECT id, name, price FROM products WHERE name LIKE ? ORDER BY {sort} {direction}"  # f-string
  └ sink       app.py:19  rows = conn.execute(query, (needle,)).fetchall()  # sqlite3.Connection.execute
  fix: Use parameterized queries: conn.execute('... WHERE id = ?', (user_id,))

1 finding(s) in 1 file(s), 4 function(s) summarized
by OWASP: A03:2021 Injection: 1

Lab 02, Fixed

Impact blocked The same sort values got a 400 and nothing from the second table appeared in a response.

The sort value is looked up in a dictionary of columns the application owns; anything else is a 400.

Lines that are not in the vulnerable version: 6, 7, 15, 16, 17, 20, 22. sinkline path: source line 15, propagate line 15, call line 15, propagate line 15, propagate line 20, sink line 22.

from __future__ import annotations

import sqlite3

from flask import Flask, jsonify, request

SORT_COLUMNS = {"id": "id", "name": "name", "price": "price"}


def create_app(settings: dict) -> Flask:
    app = Flask(__name__)

    @app.get("/api/products")
    def products():
        column = SORT_COLUMNS.get(request.args.get("sort", "name"))
        if column is None:
            return jsonify(error="unsupported sort column"), 400
        direction = "DESC" if request.args.get("order") == "desc" else "ASC"
        needle = f"%{request.args.get('q', '')}%"
        query = f"SELECT id, name, price FROM products WHERE name LIKE ? ORDER BY {column} {direction}"
        with sqlite3.connect(settings["db_path"]) as conn:
            rows = conn.execute(query, (needle,)).fetchall()
        return jsonify([{"id": i, "name": n, "price": p} for i, n, p in rows])

    return app

7 of 7 test cases passed in this version.

Recorded results of the tests that belong to this version
TestWhat it showsResult
test_fixed_endpoint_rejects_non_column_sort_valuesImpact blockedPass (3 of 3 cases)
test_fixed_endpoint_is_stricter_than_the_vulnerable_one_on_legitimate_looking_valuesFixed version stricter on odd sort values, by designPass (3 of 3 cases)
test_fixed_endpoint_keeps_normal_sorting_and_filteringFeature still worksPass

Finding SL-SQLI-001, CWE-89, critical, app.py:22. Exit status 0.

False positive: this version is fixed. False positive. sinkline treats the allow-list dictionary lookup as an unknown call and assumes passthrough.

[CRITICAL] SL-SQLI-001 A03:2021 CWE-89  app.py:22:20
  SQL built from tainted data
  flask.request.args reaches sqlite3.Connection.execute without a sanitizer for CWE-89.
  ┌ source     app.py:15  column = SORT_COLUMNS.get(request.args.get("sort", "name"))  # flask.request.args
  │ propagate  app.py:15  column = SORT_COLUMNS.get(request.args.get("sort", "name"))  # .get()
  │ call       app.py:15  column = SORT_COLUMNS.get(request.args.get("sort", "name"))  # SORT_COLUMNS.get() (unknown; assumed passthrough)
  │ propagate  app.py:15  column = SORT_COLUMNS.get(request.args.get("sort", "name"))  # assigned to `column`
  │ propagate  app.py:20  query = f"SELECT id, name, price FROM products WHERE name LIKE ? ORDER BY {column} {direction}"  # f-string
  └ sink       app.py:22  rows = conn.execute(query, (needle,)).fetchall()  # sqlite3.Connection.execute
  fix: Use parameterized queries: conn.execute('... WHERE id = ?', (user_id,))

1 finding(s) in 1 file(s), 4 function(s) summarized
by OWASP: A03:2021 Injection: 1

Lab 02, detection replay

Rule: Product API sort parameter is not a plain column name. test_detection_rule_fires_on_recorded_attack_not_on_benign_traffic: Pass.

The rule replayed over two recorded access logs
SampleEvents and alertsResult
Attack sample (recorded from the vulnerable service)4 events, 1 alertAlert
Benign sample (recorded from the fixed service)4 events, 0 alertsNo alert
sample_attack.ndjson: 4 events, 1 alert(s)
  ALERT 'Product API sort parameter is not a plain column name': 2 matching event(s)
sample_benign.ndjson: 4 events, 0 alert(s)
Lab 03, IDOR / broken object-level authorization: code, tests, sinkline and detection for both versions

Lab 03, Vulnerable

Impact demonstrated A logged-in user received another user's invoice, including its memo, and every one of the eight ids answered 200.

The read-by-id route checks that someone is logged in but not that the invoice is theirs; the list and update routes do check.

Vulnerable line the write-up names: 41.

from __future__ import annotations

import secrets

from flask import Flask, jsonify, request


def create_app(settings: dict) -> Flask:
    app = Flask(__name__)
    invoices = {item["id"]: item for item in settings["invoices"]}
    sessions: dict[str, str] = {}

    def current_user() -> str | None:
        return sessions.get(request.headers.get("Authorization", "").removeprefix("Bearer "))

    @app.post("/api/login")
    def login():
        body = request.get_json(silent=True)
        body = body if isinstance(body, dict) else {}
        user, password = body.get("user"), body.get("password")
        expected = settings["users"].get(user) if isinstance(user, str) else None
        if expected is None or not isinstance(password, str) or password != expected:
            return jsonify(error="bad credentials"), 401
        token = secrets.token_hex(16)
        sessions[token] = user
        return jsonify(token=token)

    @app.get("/api/invoices")
    def list_invoices():
        user = current_user()
        if user is None:
            return jsonify(error="login required"), 401
        return jsonify([i for i in invoices.values() if i["owner"] == user])

    @app.get("/api/invoices/<int:object_id>")
    def get_invoice(object_id: int):
        if current_user() is None:
            return jsonify(error="login required"), 401
        if object_id not in invoices:
            return jsonify(error="not found"), 404
        return jsonify(invoices[object_id])

    @app.patch("/api/invoices/<int:object_id>")
    def update_invoice(object_id: int):
        user = current_user()
        if user is None:
            return jsonify(error="login required"), 401
        invoice = invoices.get(object_id)
        if invoice is None or invoice["owner"] != user:
            return jsonify(error="not found"), 404
        invoice["memo"] = (request.get_json(silent=True) or {}).get("memo", invoice["memo"])
        return jsonify(invoice)

    return app

18 of 18 test cases passed in this version.

Recorded results of the tests that belong to this version
TestWhat it showsResult
test_vulnerable_api_returns_another_users_recordImpact demonstratedPass
test_vulnerable_patch_route_already_checks_ownershipUpdate route already checks the ownerPass
test_requests_without_a_login_are_rejected[vulnerable]Login requiredPass
test_anonymous_callers_cannot_tell_existing_ids_from_missing_ones[vulnerable]Same answer for existing and missing idsPass
test_login_rejects_unknown_users_and_malformed_bodiesUnknown users and malformed logins rejectedPass (9 of 9 cases)
test_login_rejects_known_user_with_missing_or_non_string_passwordUnknown users and malformed logins rejectedPass (5 of 5 cases)

No finding no rule fired. Exit status 0.

Not reported. Not reported, as the write-up expects: a missing authorization check is not a taint flow.

0 finding(s) in 1 file(s), 12 function(s) summarized
by OWASP: none

Lab 03, Fixed

Impact blocked Every record the user did not own answered 404 on read and on update, and the other user's memo never appeared.

One helper checks ownership and every by-id route goes through it; a record that is not yours answers 404.

Lines that are not in the vulnerable version: 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 50, 54.

from __future__ import annotations

import secrets

from flask import Flask, abort, jsonify, request


def create_app(settings: dict) -> Flask:
    app = Flask(__name__)
    invoices = {item["id"]: item for item in settings["invoices"]}
    sessions: dict[str, str] = {}

    def current_user() -> str:
        user = sessions.get(request.headers.get("Authorization", "").removeprefix("Bearer "))
        if user is None:
            abort(401)
        return user

    def owned_invoice(object_id: int) -> dict:
        user = current_user()  # authenticate before any lookup, so a missing id cannot be told apart
        invoice = invoices.get(object_id)
        if invoice is None or invoice["owner"] != user:
            abort(404)
        return invoice

    @app.errorhandler(401)
    @app.errorhandler(404)
    def error(exc):
        return jsonify(error=exc.name.lower()), exc.code

    @app.post("/api/login")
    def login():
        body = request.get_json(silent=True)
        body = body if isinstance(body, dict) else {}
        user, password = body.get("user"), body.get("password")
        expected = settings["users"].get(user) if isinstance(user, str) else None
        if expected is None or not isinstance(password, str) or password != expected:
            return jsonify(error="bad credentials"), 401
        token = secrets.token_hex(16)
        sessions[token] = user
        return jsonify(token=token)

    @app.get("/api/invoices")
    def list_invoices():
        user = current_user()
        return jsonify([i for i in invoices.values() if i["owner"] == user])

    @app.get("/api/invoices/<int:object_id>")
    def get_invoice(object_id: int):
        return jsonify(owned_invoice(object_id))

    @app.patch("/api/invoices/<int:object_id>")
    def update_invoice(object_id: int):
        invoice = owned_invoice(object_id)
        invoice["memo"] = (request.get_json(silent=True) or {}).get("memo", invoice["memo"])
        return jsonify(invoice)

    return app

18 of 18 test cases passed in this version.

Recorded results of the tests that belong to this version
TestWhat it showsResult
test_requests_without_a_login_are_rejected[fixed]Login requiredPass
test_anonymous_callers_cannot_tell_existing_ids_from_missing_ones[fixed]Same answer for existing and missing idsPass
test_login_rejects_unknown_users_and_malformed_bodiesUnknown users and malformed logins rejectedPass (9 of 9 cases)
test_login_rejects_known_user_with_missing_or_non_string_passwordUnknown users and malformed logins rejectedPass (5 of 5 cases)
test_fixed_api_hides_other_users_records_on_every_routeImpact blockedPass
test_fixed_api_still_serves_owners_their_own_recordsFeature still worksPass

No finding no rule fired. Exit status 0.

Nothing to report on the fixed version.

0 finding(s) in 1 file(s), 16 function(s) summarized
by OWASP: none

Lab 03, detection replay

Rule: Invoice read by id / One session reads many different invoice ids in a short time (correlation: value_count of object_id by session, 5 or more in 1m). test_detection_rule_fires_on_recorded_enumeration_not_on_benign_traffic: Pass.

The rule replayed over two recorded access logs
SampleEvents and alertsResult
Attack sample (recorded from the vulnerable service)11 events, 1 alertAlert
Benign sample (recorded from the fixed service)4 events, 0 alertsNo alert
sample_attack.ndjson: 11 events, 1 alert(s)
  ALERT 'One session reads many different invoice ids in a short time': 6 matching event(s)
sample_benign.ndjson: 4 events, 0 alert(s)
Each fix stops the impact its lab's own tests show on the vulnerable service. The other checks agree less: sinkline reports the two injection-style bugs and also their fixed versions, nothing on the missing-authorization bug, and the detection rules alert on the attempts whether or not the service is fixed. Recorded, not live: every result is output of a real run of vulnlab v0.1.1 (commit dd2665d) on 2026-11-11: Python 3.12.2, pytest 9.2.0, sinkline 0.5.0, tripline 0.2.1, pySigma 2.1.0, Windows 11; python -m pytest -v -p no:cacheprovider, then sinkline and the Sigma replay per lab. The page evaluates nothing; the switch only chooses which recorded result to show. Everything ran against the lab's own services on loopback. Regenerate with python scripts/capture_vulnlab_bench.py; node scripts/test_bench_vulnlab.mjs checks that the page shows exactly the stored data.
Checked against
Each lab's fixed service and its tests; sinkline 0.5.0; Sigma rules replayed on recorded logs
Found, then left
As found 3 vulnerable services (SSRF, SQL injection, IDOR) show impact in their tests
As left 3 fixes block it; 137 tests pass; loopback only
Limits
Training lab; bugs simpler than real ones; no CVEs; detection fires on attempts, not impact Full limits
Technical description and verification record: instrument, reference standard, date, how to reproduce (self-issued, not accredited)

A localhost-only training lab: three small Flask services, each with one textbook web bug (SSRF through a redirect, SQL injection in ORDER BY, a missing object-level authorization check). Every lab has a fixed version, tests that show the impact on the vulnerable service and then the same checks failing against the fix, a Sigma rule replayed on recorded logs, and a record of what sinkline does and does not report. It is a training lab, not vulnerability research.

Instrument
vulnlab v0.1.1, tag commit dd2665d (released 2026-11-11)
Reference standard
each lab's fixed service and its tests: the same checks that show the impact on the vulnerable service must fail against the fix; sinkline 0.5.0 and a Sigma rule per lab are the other two checks
Measured on
2026-11-11, Python 3.12.2, Windows 11, fresh clone at tag v0.1.1; CI (Linux) run 43134837698
Issued by
The author. Self-issued verification record, not accredited.
Reproduce
pip install -r requirements.lock
pytest -q
python tools/sinkline_check.py
sigma check labs/*/detection/*.yml

As foundThree vulnerable services, one missing check each

Each vulnerable service is under 60 lines, has no comment pointing at its bug, and does the intended job. What goes wrong is one missing check, and each test first confirms that the intended protection works for the direct case, then shows the impact. Everything the tests touch is data the test created: a marker string, a temporary SQLite file, seeded records.

  • vulnerable service shows the impact
  • fixed service blocks the impact

3 vulnerable services show their impact in a test

3 fixed services block it, checked by the same tests

What each vulnerable service does wrong, the line, and the impact its test observes
LabWhat it does wrongRoot cause (vulnerable line)What the test observes
01 SSRF through a redirect
CWE-918
A link-preview endpoint checks the host of the URL it was given once, then fetches it with a client that follows redirects on its own.The allow-list never sees the second URL, so an allowed host that redirects anywhere is a pass to anywhere.
if not host_allowed(url):
    ...
upstream = requests.get(url, timeout=3)
The direct request to the internal address got 403. The request through the allowed host's redirect got 200, and the preview contained the internal service's marker; the internal service logged one request it should never have received.
02 SQL injection in ORDER BY
CWE-89
A product list binds the search text as a query parameter, so the query looks safe, but the sort column is pasted into the SQL string.Placeholders cannot stand in for identifiers, so the placeholder protects one position and the f-string opens another.
query = f"... ORDER BY {sort} {direction}"
Ordinary sort values behaved. A sort value that depends on a second table changed the order of the rows that came back: data from a table the endpoint never returns decided what it returned. The test stops there and does not recover the marker.
03 IDOR (broken object-level authorization)
CWE-639
An invoice API checks that the caller is logged in on the read-by-id route but not that the invoice is theirs. The list and update routes do check.Each route re-implements the rule by hand and one forgot.
if current_user() is None:
    ...
return jsonify(invoices[object_id])
A logged-in user's own list contained only their invoices. Asking for another user's invoice returned 200 with that user's memo, and all eight ids answered 200. On the same record the update route already answered 404, so the inconsistency is part of the evidence.
IDOR and BOLA name the same flaw: the server trusts an object id from the client without checking that the caller may use that object (OWASP API1:2023, CWE-639). Sequential ids make other records easy to find but are not the bug; random ids would only make a missing check harder to exploit.

As leftThree fixes, checked by the tests that showed the impact, and four defects of the first release fixed

Each fix is small and each lab's tests show it from two sides: the impact check that passed on the vulnerable service fails on the fix, and a second test shows the fixed service still does its job, so a fix that breaks the feature is not accepted. The three labs' own tests (72) and the tests of the loopback guard, the harness, the replay code, the captured output and the Sigma rules (65) all pass: 137 in all.

The three fixes, the tests that check them and what changed
LabThe fixImpact check on the fixFeature check on the fix
01 SSRF through a redirectThe client no longer follows redirects. The app follows them itself, at most three hops, and checks every hop's host and its resolved address, then connects to that checked address. Only http is accepted, and a malformed URL gets a 400.Both the direct and the redirected request got 403, the marker was absent and the internal service was never contacted.A page on the partner site, directly and through an allowed redirect, still previews.
02 SQL injection in ORDER BYThe sort value is looked up in a dictionary of the three columns the application owns; the text interpolated into the SQL is the dictionary's value, never the caller's. Anything else is a 400.Three different non-column sort values each got 400 and nothing from the second table appeared.Sorting by each allowed column in both directions and filtering by search text still work.
03 IDOROne helper decides whether an invoice is the caller's, and every by-id route goes through it. A record that is not yours answers 404, the same answer as "does not exist", so responses do not confirm which ids are in use. Authentication is checked before the lookup, so a caller with no token gets the same 401 for an existing and a missing id.Every record the user did not own answered 404 on read and on update, and the other user's memo never appeared.An owner can still read and update their own invoices and list them; requests without a login get 401.

Defects of the first release, found in a review and fixed in 0.1.1

The first release passed its own tests and still had four defects that the tests did not catch. The labs' own tests now cover each one; the fixes change the services' behavior on bad input and add no attack content.

Table, 4 rows: what the review of v0.1.0 found, what v0.1.1 does instead, and the test
What the review of v0.1.0 found, what v0.1.1 does instead, and the test
WhereAs found (v0.1.0)As left (v0.1.1)Test
Lab 03, both variants: loginA login with a missing or null password for an unknown user was issued a token (a missing value was compared with a missing value), and a malformed login body caused an unhandled error.Unknown users with a missing or null password and malformed bodies get 401.14 cases in each variant
Lab 03, fixed: order of checksThe object lookup ran before authentication, so a caller with no token could tell which ids exist.Authentication comes first: the same 401 for an existing and a missing id.One case in each variant
Lab 01, fixed: malformed URLsInvalid or zero ports, an unterminated IPv6 literal, a missing host, credentials in the URL, a non-http scheme and an over-long URL gave an unhandled 500 or a 403.A clean 400 for each; a well-formed URL for an unapproved host is still a 403.18 URL cases, 2 redirect-target cases and 1 for the 403
Lab 01 tests: address checkRemoving the check of the resolved address left every test green.A test fails if the address check is removed.1 case

The same review found that the replay code counted a missing field as a distinct value (now a unit-tested case), that tools/sinkline_check.py could run a different sinkline from the one the repository pins (it now runs the active interpreter's sinkline and exits 2 on a version mismatch), and that the README said 43 tests pass when the run reported more. The recorded sinkline output of lab 01 changed line numbers because the fixed service grew; its findings did not change.

The other checks, side by side: sinkline and the Sigma rules (table, strips and notes)

The other checks, side by side

The tests are the reference. The other two checks are scored against what each lab is known to contain, and they agree with it less.

Recorded results of the three checks on the six services
CheckVulnerable versionsFixed versionsVerdict
The lab's own testsImpact demonstrated in 3 of 3Impact blocked in 3 of 3, features intactAs left: 3 of 3 fixes verified
sinkline 0.5.0Reports labs 01 and 02, correctly. Reports nothing on lab 03.Still reports labs 01 and 02, which are false positives. Nothing on lab 03.2 of 3 found, 2 false positives
Sigma rules, replayed on recorded logs1 alert on each lab's attack logNo alert on each benign log; the attempt against the fixed service still alerts, because the rules see attempts, not outcomesFires on attempts
  • vulnerable, reported
  • vulnerable, not reported
  • fixed, reported (false positive)
  • fixed, not reported

sinkline 0.5.0 reports 2 of 3: SSRF and SQL injection. The IDOR is not a taint flow

2 of 3 reported anyway: false positives on the SSRF and SQL injection fixes

The write-ups say why. sinkline reports a tainted value reaching a dangerous call; it does not recognize a custom allow-list or address check as a sanitizer, and treats a dictionary lookup against constant column names as a pass-through, so it cannot tell the fixes from the bugs in labs 01 and 02. On lab 01 it also does not say that the bug is the redirect: "tainted URL reaches requests.get" would be just as true of an app with no allow-list at all. Lab 03 has no tainted value reaching a sink: the id is used only as a dictionary key, and whether the caller may see the record is a rule of the application that a source-to-sink analyzer has no model of. Finding that class takes authorization tests, a policy layer that fails closed, or review against an access matrix.

The lab was not changed to hide any of this.

Technical details

How it works

design, implementation points and diagram

Shape of a lab

Each lab is a directory with a vulnerable/app.py and a fixed/app.py (a Flask app with a create_app(settings) function), a lab.py that starts the services for one variant on free loopback ports, test_lab.py, a write-up, the captured sinkline output and a detection/ directory with a Sigma rule and two recorded access logs. The two app files differ only where the fix is.

Four statements per lab, each a test

  1. Proof on the vulnerable service: a control first (the intended protection works for the direct case), then the observable impact.
  2. The same request against the fixed service does not produce the impact.
  3. The fixed service still does its job.
  4. The detection rule fires on the recorded attack log and not on the recorded benign log, and the attempt against the fixed service is still visible to the rule.

Access log and detection

The harness appends one JSON object per request to a log file: timestamp, client address, method, path, raw query string, status, a short hash of the Authorization header as session and the route's object_id. Field names follow W3C-style web-server logs, so the rules read like web-server rules. Single-event rules are compiled by tripline's engine (pySigma parses, tripline walks the condition tree), with its logsource router bypassed because that router knows only Windows, Linux auditd and CloudTrail. Lab 03's rule is a correlation (an event count of distinct object_id values per session within a minute) that tripline does not implement, so the lab's own windowing code in vulnlab_common/replay.py evaluates it.

One real run

Run on 2026-11-11 from a fresh clone at tag v0.1.1 (Windows 11, Python 3.12.2) as bash tools/demo.sh, which runs lab 01 end to end. Trimmed: the pytest header and timings are removed, and the two sinkline reports are on Plate 1.

=== 1. vulnerable service: the preview endpoint returns the internal service's canary via an allowed redirect ===
labs/01-ssrf-redirect/test_lab.py::test_vulnerable_service_returns_internal_marker_via_allowed_redirect PASSED
=== 2. what sinkline reports on the vulnerable version ===
[HIGH] SL-SSRF-001 ... CWE-918  app.py:23:24   (full output on Plate 1)
=== 3. fixed service: the same request is refused and the internal service is never contacted ===
labs/01-ssrf-redirect/test_lab.py::test_fixed_service_does_not_return_internal_marker PASSED
labs/01-ssrf-redirect/test_lab.py::test_fixed_service_still_previews_pages_and_follows_allowed_redirects PASSED
... 21 more cases, all PASSED (not shown: malformed URLs and redirect targets, one 403 case)
=== 4. what sinkline reports on the fixed version (a documented false positive) ===
[HIGH] SL-SSRF-001 ... CWE-918  app.py:62:24   (full output on Plate 1)
=== 5. replay the Sigma rule over the recorded logs ===
sample_attack.ndjson: 2 events, 1 alert(s)
  ALERT 'URL preview request names an internal or metadata address': 2 matching event(s)
sample_benign.ndjson: 2 events, 0 alert(s)

Safety scope

Everything binds to loopback. The harness refuses to start a service on any other host, and every request the tests make goes through a client that checks the URL first and accepts only localhost or a literal loopback address, with no DNS lookup. The stand-in "internal" address in lab 01 is 127.0.0.2, which is still loopback. 26 tests under tests/ assert the guard, including look-alike names and private and link-local addresses, and that no lab test names a non-loopback URL. There is no shell, persistence, evasion or scanning code in the repository.

Tolerance and limitsWhat the numbers do not show

  • It is a training lab, and the bugs are simpler than real ones. Each is a textbook class written small enough to fit on one screen: one file, one bug, in-memory or temporary data, no framework configuration, no concurrency and no real identity provider. Finding such a bug in real code is the hard part, and these labs do not exercise it.
  • No CVEs, no disclosures, no findings against real software. None are claimed, and no real product or vendor is reproduced.
  • Scope is loopback only. The services run on 127.0.0.1 and start on random ports; the repository's own guard refuses anything else. It is not a scanner, an exploitation toolkit or a target for anything else, and the vulnerable apps must not be deployed or exposed on a network.
  • sinkline reports both fixed injection-style services, because it does not recognize a custom allow-list, address check or constant-column lookup as a sanitizer. Those two findings are false positives. It reports nothing on the missing-authorization lab, vulnerable or fixed, because that bug is not a taint flow: the blind spot of static taint analysis, not a defect of the scan.
  • The detection rules see attempts, not impact. They fire on the fixed services too. Lab 01's rule matches literal internal and metadata addresses and misses an internal target named by hostname or written in another address form; lab 02's matches anything in the sort value other than letters, digits and underscore, so a client that legitimately sends a multi-column sort would be a false positive.
  • The IDOR rule is a correlation with an untuned threshold. Five or more distinct invoice ids per session in a minute is evaluated by the lab's own windowing code, since tripline has no correlation support. A single targeted read of one other user's invoice produces no alert at all, which is the real gap: this class has no request-level signature.
  • The fixes are checked by the tests listed, not proven. Lab 01's fix accepts only http: pinning an address for https needs a custom adapter that keeps SNI and certificate checks on the original hostname, and that is not done here. Nothing was tested against real traffic.
  • The page shows recorded results. It evaluates nothing, runs no service and sends no request. Request strings and logs are not reproduced here; the observable results are described in prose and the labs' own tests are linked below.
Evidence

Where each number comes from

11 links

Chips show the state of each GitHub Actions run as captured when this page was built. The page does not call GitHub at runtime.

CI on main, tag commit (ci, v0.1.1)
run 43134837698 passed 2026-11-12
Release
v0.1.1
Changelog 0.1.1
CHANGELOG.md
Harness and replay code
vulnlab_common/
sinkline capture and check
tools/sinkline_check.py
Lab explorer capture script
scripts/capture_vulnlab_bench.py
Lab explorer consistency test
scripts/test_bench_vulnlab.mjs

The sinkline text files recorded in the repository (labs/*/sinkline/*.txt) showed the path glyphs as garbled characters in v0.1.0, a character-encoding slip when they were written on Windows; v0.1.1 stores them as UTF-8 and a test guards that. The capture script for this page checks that the recorded text equals a fresh run of sinkline 0.5.0 and shows the glyphs the tool prints.