Open Redirect in CTFd Login and Registration Flow
Summary
CTFd validates the post-authentication redirect target (next) with
_is_safe_url, which rejects protocol-relative targets. The check runs against a
string that still contains a tab character, but the browser strips that tab
while parsing the eventual 3xx response and resolves the result to an external
host. Because the validator and the browser disagree on what the same value
means, a crafted next= parameter on /login or /register bounces the user
off-site once the flow completes. This is a textbook parsing differential.
Suggested rating: Medium (CVSS 4.3), vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N.
Details
The redirect allow-check lives in CTFd/utils/validators/__init__.py:
| |
The payload is a two-slash prefix, a URL-encoded tab (%09), a slash, and the
attacker host:
| |
Walking the validator with target = "//\t/example.com":
target.startswith("///")isFalse. The literal three-slash guard never sees a//with a tab wedged in the middle, so this passes.urlsplit(target)strips the tab (modern Python removes\t,\r, and\nfrom URLs during parsing), leaving///example.com. That parses asscheme='' netloc='' path='/example.com': an empty authority followed by an absolute path.not url_info.netloc and url_info.schemeis falsy (no scheme), so it is not rejected.target[0]is/, which is not a control character.- The final check resolves the target against the site origin. Python’s
urljoin("http://localhost:8000/", "//\t/example.com")also strips the tab and, seeing an empty authority, inherits the base host and treatsexample.comas a path segment, yieldinghttp://localhost:8000/example.com. Its netloc matches the reference netloc, so the value is judged safe.
Every check agrees that this is a same-origin path. The browser does not.
When CTFd echoes the original value into the redirect Location, the browser
strips the tab to get ///example.com and applies the WHATWG URL rules for
special schemes (http/https). Those rules skip any run of leading slashes
before the authority (“special authority ignore slashes”), so ///example.com
collapses to the host example.com rather than an empty authority plus a path.
Python validated the pre-parse string as same-origin; the browser acts on the
post-parse string and navigates off-site.
Source:
CTFd/utils/validators/__init__.py#L28
Proof of Concept
Tested against the official CTFd Docker image for version 3.8.4
(sha256:da25249c41d19556573f11cf3c9fd887d47830310ae8d725ad443644a35b3455) on
macOS, with the web UI on port 8000. Reproduces deterministically.
Both entry points accept the payload:
| |
After the user authenticates or registers, the browser follows the redirect to
http://example.com/.
The browser’s own parser shows why the host is honored:
| |
Impact
An attacker who controls the next parameter in a login or registration link
can land the victim on an arbitrary host once they complete the flow. The
practical payoff is phishing: a legitimate, correctly-branded CTFd link that
ends on a lookalike page built to capture credentials or serve a malicious
download. The trust the victim places in the real CTFd domain carries over to
the attacker’s destination.
Remediation
Upgrade to CTFd 3.8.5, which strips the tab and newline characters before validating the redirect target.
Normalize the redirect value the same way the browser will before deciding it
is safe: strip \t, \r, and \n, then reject any target whose authority is
not the site origin. Better still, validate against a tight allowlist of
relative paths rather than blocking known-bad prefixes, since prefix blocklists
lose to parsing differentials like this one.
The code already notes the right reference in a TODO: Django solves the same
problem with url_has_allowed_host_and_scheme, which is a good model to follow
in the same language.
Disclosure Timeline
- 2026-05-13: Reported to CTFd. Acknowledged the same day, with a note that the issue had already been reported by another researcher.
- 2026-05-13: CTFd shared a candidate patch and asked for a review.
- 2026-05-14: Confirmed the patch resolves the issue, with no further bypasses found.
- 2026-05-19: Patch merged to the open-source repository and CTFd 3.8.5 released.
- 2026-05-28: CTFd confirmed no restrictions on public disclosure once users have had time to patch.
References
Notes
This issue was independently discovered as part of URL parsing differential research conducted with a US-based academic research lab. CTFd had already received the same report from another researcher, who received the associated bounty. The fix shipped in CTFd 3.8.5, and CTFd places no restrictions on disclosure once users have had adequate time to patch.