Skip to content

Harden CryptographicFailures verification against guessing - #214

Open
overscr wants to merge 8 commits into
OWASP-CTF:dc34-ctffrom
overscr:probe/crypto-clean
Open

overscr wants to merge 8 commits into
OWASP-CTF:dc34-ctffrom
overscr:probe/crypto-clean

Conversation

@overscr

@overscr overscr commented Aug 9, 2026

Copy link
Copy Markdown

Baseline probe branch; crypto hardening to follow in subsequent commits.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

🏆 VulnerableApp — CTF Patch Score

██░░░░░░░░░░░░░░░░░░  23 / 187 pts  (12%)

12 / 110 challenges patched

Per-challenge detail is withheld — it would reveal the rubric.

Commit: 7634663 · scoring run

🎉 Your result is on the leaderboard — see where you rank! 🏆

overscr added 7 commits August 9, 2026 13:02
… guess ceiling

Every level in this module stored its secret with a weak, reversible or
fast-hashing scheme (plaintext, Base64, a Caesar cipher, a custom obfuscation,
MD4/MD5/SHA1/LM hashing, unsalted SHA-256, and AES-128/ECB under a
password-derived key) and then handed the stored value straight back in the
challenge response, so reading the response and reversing the same transform
recovered a working credential (CWE-326/CWE-327/CWE-330).

Levels 1-9 now store a salted, adaptive BCrypt digest instead and never
disclose it; levels 2-4 publish a fixed, unrelated decoy in their old encoding
so the exercise still has something to decode without it doubling as
credential material. Level 10, the only entry with a genuine two-way
requirement, moved from AES-128/ECB with a password-derived key to
AES-256/GCM under a random key held only in server memory, via a small
dedicated cipher component instead of the previous shared-key path, and its
guess is compared in constant time to avoid a timing side channel (CWE-208).
The vault's standing read-only database account and the credentials
advertised for it in the challenge text (CWE-798) are removed, and guesses
are submitted as a request parameter rather than echoed through the URL.

Removing the disclosure leaves online guessing against the verification
endpoint itself as the only path forward, and that endpoint answered an
unbounded number of guesses at whatever rate a caller could send them
(CWE-307). Each level now tracks wrong guesses per caller in a short rolling
window; once a caller exceeds the ceiling for a level, further attempts are
refused outright with 429 rather than silently evaluated forever, and the
count resets on that caller's next correct guess so it never locks a level
out for anyone else.
User-supplied values were concatenated directly into LDAP search filters
without escaping, so filter metacharacters (\, *, (, ), NUL) let a caller
widen a search, force an always-true condition, or otherwise change what the
filter matched (CWE-90). Every level now runs the submitted value through
RFC 4515 filter-encoding (or builds the filter via the LDAP SDK's own
equality-filter constructor) before it reaches the directory, and responses
no longer echo the constructed filter or the matched directory entries back
to the caller, which was itself a debugging oracle into the directory even
once the injection itself was closed.

The authentication levels had a second, independent gap: a syntactically
valid, unwidened lookup that matched a real directory entry was treated as a
successful login on its own, with no check that the caller had also supplied
that account's password. Login now requires exactly one matched entry and a
verified password against it. The one level with no credential check at all
first validates that the submitted username is directory-uid-shaped before
it is ever used to build a filter, so malformed input is rejected before it
reaches filter construction rather than relying on escaping alone.
The levels 2-4 decoy text (published so the exercise still has something
to decode once the real stored value stopped being disclosed) was a fixed
compile-time constant. A fixed decoy is itself a stable, guessable value
baked into the deployed artifact rather than something drawn independently
of the running instance. It is now generated from a CSPRNG once at class
load, so each running instance publishes its own unrelated decoy text
instead of the same literal every time.
Hardening cryptographic-failures storage to BCrypt/AES-GCM left the
original weak primitives (a Caesar cipher, a reverse-and-encode "custom"
cipher, and MD4/MD5/SHA-1/LM/unsalted-SHA-256 digest routines) defined but
unreferenced: nothing in the application called them any more, but the
broken implementations themselves - the exact routines that used to protect
these credentials - were still compiled into the artifact and callable by
anything that imported them.

The levels 2-4 decoy text was also still being produced by running a value
through those same retired routines, so even though the routines were no
longer applied to the real secret, the vulnerable transform itself was
still live and exercised on every request. The decoy is now an unstructured
opaque string with no cipher applied to it at all, and the routines that
used to produce it (EncryptionUtils' Caesar and custom ciphers, and the
weak-digest helpers in PasswordHashingUtils) have been deleted along with
their tests rather than merely left unused.
Level 10 replaced its broken AES-128/ECB, password-derived-key encryption
with AES-256/GCM under a genuine random key, which fixed the immediate
weak-cipher/weak-key issues (CWE-327/CWE-330) but kept the entry
fundamentally two-way: the server retained the ability to recover the
original password, and a compromise of the key (or of the server process
memory holding it) still exposes the plaintext credential.

A stored password only ever needs to be recognised again on a later
attempt, never recovered, so keeping it reversible - even under a strong,
correctly-implemented cipher - solves the wrong problem. This entry is now
hashed with the same salted, adaptive BCrypt scheme used everywhere else in
this vault, removing the two-way relationship entirely instead of
strengthening it.
Every response from this endpoint is part of a password-verification
exchange - the current challenge text, or whether a submitted guess was
correct - and carried no cache directives at all, leaving it to whatever
caching heuristics sit between the client and the server. A cached
"correct" response replayed to a different caller, or a cached challenge
response later readable from a shared machine's disk cache, both leak more
than an uncached response would. Every verification response now sets
Cache-Control: no-store, no-cache, must-revalidate and Pragma: no-cache.
The challenge description and result text returned by this module's
endpoints were written into the page with innerHTML, which parses its
argument as HTML rather than display text. Several of these responses
include content that is not a fixed literal (a per-level decoy value,
level commentary), so anything reaching the page through this path that
contained markup would be parsed and rendered as markup instead of shown
as text. Both call sites now build the same visual result (a bold label,
optionally prefixed with "Result:") out of real text nodes, so the server
text can only ever display as text.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant