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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def _is_safe_url(target):
    # TODO: CTFd 4.0 In Django this was renamed to `url_has_allowed_host_and_scheme`. Consider similar.
    if target.startswith("///") or len(target) > MAX_URL_LENGTH:
        return False
    try:
        url_info = urlsplit(target)
    except ValueError:  # e.g. invalid IPv6 addresses
        return False
    # Forbid URLs like http:///example.com - with a scheme, but without a
    # hostname. In that URL, example.com is not the hostname but a path
    # component. However, Chrome will still consider example.com to be the
    # hostname, so we must not allow this syntax.
    if not url_info.netloc and url_info.scheme:
        return False
    # Forbid URLs that start with control characters.
    if unicodedata.category(target[0])[0] == "C":
        return False
    ref_url = urlparse(request.host_url)
    test_url = urlparse(urljoin(request.host_url, target))
    return test_url.scheme in ("http", "https") and ref_url.netloc == test_url.netloc

The payload is a two-slash prefix, a URL-encoded tab (%09), a slash, and the attacker host:

1
//%09/example.com

Walking the validator with target = "//\t/example.com":

  1. target.startswith("///") is False. The literal three-slash guard never sees a // with a tab wedged in the middle, so this passes.
  2. urlsplit(target) strips the tab (modern Python removes \t, \r, and \n from URLs during parsing), leaving ///example.com. That parses as scheme='' netloc='' path='/example.com': an empty authority followed by an absolute path.
  3. not url_info.netloc and url_info.scheme is falsy (no scheme), so it is not rejected.
  4. target[0] is /, which is not a control character.
  5. 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 treats example.com as a path segment, yielding http://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:

1
2
http://localhost:8000/login?next=//%09/example.com
http://localhost:8000/register?next=//%09/example.com

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:

1
2
new URL("//\t/example.com", "http://localhost:8000")
// => http://example.com/

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.