Engineering · Aug 7, 2026 · 6 min read

CRLF injection: when a newline becomes a header boundary

A percent-encoded carriage-return plus line-feed in a reflected parameter terminates the intended response header and starts a new one under attacker control - the sink it reaches decides whether that is low noise or a cookie-injection path.

CRLF injection lands in the gap between URL decoding and header output. When a server reads a request parameter, decodes it, and writes the result directly into a response header, a percent-encoded carriage-return plus line-feed - %0d%0a - terminates the header the server intended to write and starts a new one under the attacker's control. A reflected test header with a benign nonce proves the boundary is open without touching any cookie, redirect target, or content-type that would affect a real session.

Proven - HighCRLF injection confirmed - /redirect?url (Location header)
GET /redirect?url=https://example.com%0d%0aX-CRLF-Probe:62615533 ──► HTTP/1.1 302 Found Location: https://example.com X-CRLF-Probe: 62615533 <-- injected header present in cold response baseline (no %0d%0a): Location only, no injected header. Delta holds across 3 repeats.
Oracle: presence/absence of injected header in response (differential). No malicious content; probe header carries a benign arithmetic nonce. Severity high - same mechanism injects Set-Cookie and rewrites Content-Type.
3
distinct severity sinks
0
cookies set or stolen during probe
1
CRLF pair confirms the boundary

01How a newline becomes a header boundary

HTTP/1.1 headers are separated by the two-byte sequence CR LF (\r\n, ASCII 0x0D 0x0A). A status line ends with one; each header field ends with one; the blank line separating headers from the body is two in a row. When a server reads a URL-decoded parameter value and writes it verbatim into a header field - a Location redirect, a Set-Cookie name, a Content-Disposition filename - any CRLF still in the value is treated by the client's parser as a real header terminator. Everything after the injected \r\n becomes a new header field the application never wrote.

What the server intended to write HTTP/1.1 302 Found\r\n Location: https://example.com/dashboard\r\n \r\n (blank line = end of headers) What the client parses after %0d%0a injection in the url parameter HTTP/1.1 302 Found\r\n Location: https://example.com\r\n X-CRLF-Probe: 62615533\r\n <-- attacker-controlled header starts here \r\n
The percent-encoded CRLF survives URL decoding and terminates the Location header before the server intended. A new header starts at the attacker-supplied value.

The same mechanism that injects a harmless probe header can inject Set-Cookie: session=forged, Content-Type: text/html, or a second Location header that overwrites the intended redirect target - which of those sinks is reachable decides the final severity, not the CRLF character pair itself.

02The confirming probe: benign nonce, differential check

The proof requires two requests. First a baseline: the unmodified parameter, recording every response header the server returns. Second, the injection probe: %0d%0a appended to the reflectable value, followed by a header name and a benign arithmetic nonce. The confirming artifact is the injected header appearing in the probe response and being absent from the baseline response. No malicious content is ever sent - the nonce is readable noise, not a payload that executes or sets a real cookie.

crlf-probe.httphttp
# 1. Baseline - record all response headers
GET /redirect?url=https://example.com
#    HTTP/1.1 302  Location: https://example.com  (no extra headers)

# 2. CRLF probe - inject a benign header after the encoded CRLF
GET /redirect?url=https://example.com%0d%0aX-CRLF-Probe: 62615533
#    HTTP/1.1 302
#    Location: https://example.com
#    X-CRLF-Probe: 62615533    <-- injection confirmed

# Repeat probe 2 more times to confirm not a server-side fluke.
# Then probe for Set-Cookie and Content-Type sinks to grade severity.

Some servers strip bare %0d%0a but pass the double-encoded form %250d%250a - or vice versa. Probe both. Frameworks that decode only once (Express, Flask, Spring MVC with default UriComponentsBuilder) stop at the first decode layer; those that decode twice, or that pass the value through an intermediary that decodes again, expose the double-encoded form. The confirmed form is the one that belongs in the finding.

03Severity by sink: from noise to XSS

A CRLF pair that reaches a response header does not have a fixed severity - the sink decides it. Testing only whether the CRLF survives and stopping there conflates a low-impact header reflection with a confirmed XSS delivery path.

SinkInjected headerImpactSeverity
Custom debug headerX-Debug: attacker-valueReflected noise; no user-visible effectLow - confirmed injection, minimal impact
Location redirectLocation: https://attacker.exampleOpen redirect served from a trusted originMedium - browser follows without warning
Set-CookieSet-Cookie: session=forged; Path=/Cookie injection or session fixation on victim's browserHigh - authenticated context can be planted
Content-Type + body splitContent-Type: text/html\r\n\r\n<script>...HTTP response splitting delivers XSS from trusted originCritical - arbitrary script in victim's browser origin

The Content-Type escalation is the most common path to critical. HTTP/1.1 servers that buffer and stream responses separately may split the body at the injected blank line, yielding a malicious HTML document served under the application's domain. HTTP/2 servers are generally not vulnerable to classic response splitting because the framing layer is binary and header fields are encoded without CRLF as a delimiter - but a backend that speaks HTTP/1.1 behind an HTTP/2 front-end still exposes the injection surface at the hop where HTTP/1.1 is used.

Proven - HighCRLF - Set-Cookie injection - /search?q (Content-Disposition header)
GET /search?q=report%0d%0aSet-Cookie:__test_nonce=62615533; Path=/ ──► HTTP/1.1 200 OK Content-Disposition: attachment; filename=report Set-Cookie: __test_nonce=62615533; Path=/ baseline: no Set-Cookie header. Injected cookie confirmed absent on clean request.
Oracle: presence/absence differential. No real session forged. Severity high - same mechanism plants a real session cookie on a victim browser. Fix: strip or reject CR and LF from any value written into a header field.

04Where frameworks protect and where they do not

Modern framework defaults vary widely. Knowing which layer actually sanitizes - and which assumes the caller already did - is what prevents a false confidence in framework protection.

framework-notes.txttext
# Express (Node.js)
res.setHeader('Location', userInput)
  throws TypeError if \r or \n present  # protected since Node 14.18

# Flask (Python)
redirect(userInput)  # strips newlines in Werkzeug >= 2.1
Response(headers={'Location': userInput})  # older versions: NOT stripped

# Spring (Java)
response.sendRedirect(userInput)  # encodes \r\n since Spring 5.3.22
HttpHeaders.set('X-Custom', userInput)  # direct header set: NOT filtered pre-6.0

# Ruby on Rails
redirect_to userInput  # raises ArgumentError on CRLF since Rails 6.0
response.headers['X-Custom'] = userInput  # NOT filtered

The pattern is consistent: high-level helpers (redirect, send_file, set_cookie wrappers) gained CRLF guards over time; direct header-field assignment almost never has them. Code that constructs custom headers, Content-Disposition filenames, or X-forwarded-* values from user input is the surface a scanner needs to probe regardless of framework version, because the guard lives in the convenience method, not in the header write path itself. A version bump that protects redirect_to does not protect raw response.headers[]=.

The fix is unconditional: strip or reject any CR or LF character from user-supplied values before they reach any header-write path. Allowlist-based output encoding - permitting only printable ASCII characters excluding 0x0D and 0x0A in header values - removes the injection surface without breaking legitimate redirects or filenames. A finding that reaches a Set-Cookie or Content-Type sink carries a 7-day SLA; a finding that reaches only a benign custom header is a 30-day remediation item, because the root cause is identical and the only protection is the sink not being interesting yet.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →