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
| Lab | What it does wrong | Root 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. | 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. | 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. | 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. |
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.
| Lab | The fix | Impact check on the fix | Feature check on the fix |
|---|---|---|---|
| 01 SSRF through a redirect | The 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 BY | The 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 IDOR | One 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
| Where | As found (v0.1.0) | As left (v0.1.1) | Test |
|---|---|---|---|
| Lab 03, both variants: login | A 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 checks | The 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 URLs | Invalid 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 check | Removing 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.
| Check | Vulnerable versions | Fixed versions | Verdict |
|---|---|---|---|
| The lab's own tests | Impact demonstrated in 3 of 3 | Impact blocked in 3 of 3, features intact | As left: 3 of 3 fixes verified |
| sinkline 0.5.0 | Reports 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 logs | 1 alert on each lab's attack log | No alert on each benign log; the attempt against the fixed service still alerts, because the rules see attempts, not outcomes | Fires 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 detailsHow 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
- Proof on the vulnerable service: a control first (the intended protection works for the direct case), then the observable impact.
- The same request against the fixed service does not produce the impact.
- The fixed service still does its job.
- 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.