From 9c1dbaa597aff6d2fbf9fb6105f30d537b8a0c4a Mon Sep 17 00:00:00 2001 From: dmiller Date: Wed, 26 Jul 2017 14:37:34 +0000 Subject: [PATCH] FTP overhaul: better SSL connection, including ftp-anon over ftps or STARTTLS --- nselib/ftp.lua | 49 ++++++++++++++++---- scripts/ftp-anon.nse | 75 +++++++++++++++++++++++++------ scripts/ftp-vsftpd-backdoor.nse | 9 ++-- scripts/ftp-vuln-cve2010-4221.nse | 14 +++--- 4 files changed, 110 insertions(+), 37 deletions(-) diff --git a/nselib/ftp.lua b/nselib/ftp.lua index d553094b9..d104a3272 100644 --- a/nselib/ftp.lua +++ b/nselib/ftp.lua @@ -14,28 +14,59 @@ local ERROR_MESSAGES = { ["ERROR"] = "failed to receive data" } ---- Connects to the FTP server based on the provided options. +local crlf_pattern = "\r?\n" +--- Connects to the FTP server based on the provided options and returns the parsed banner. -- -- @param host The host table -- @param port The port table --- @param opts The connection option table, possible options: --- timeout: generic timeout value --- recv_before: receive data before returning +-- @param opts The connection option table, from comm.lua. -- @return socket The socket descriptor, or nil on errors --- @return response The response received on success and when --- the recv_before is set, or the error message on failures. +-- @return code The numeric response code, as returned by read_reply, or error message if socket is nil. +-- @return message The response message +-- @return buffer The socket read buffer function, to be passed to read_reply. +-- @see comm.lua connect = function(host, port, opts) - local socket, _, _, ret = comm.tryssl(host, port, '', opts) + opts = opts or {} + opts.recv_before = true + local socket, err, proto, ret = comm.tryssl(host, port, '', opts) if not socket then return socket, (ERROR_MESSAGES[ret] or 'unspecified error') end - return socket, ret + local buffer = stdnse.make_buffer(socket, crlf_pattern) + local pos = 1 + -- Should we just pass the output of buffer()? + local usebuf = false + -- Since we already read the first chunk of banner from the socket, + -- we have to supply it line-by-line to read_reply. + local code, message = read_reply(function() + if usebuf then + -- done reading the initial banner; pass along the socket buffer. + return buffer() + end + -- Look for CRLF + local i, j = ret:find(crlf_pattern, pos) + if not i then + -- Didn't find it! Grab another chunk (up to CRLF) and return it + usebuf = true + local chunk = buffer() + return ret:sub(pos) .. chunk + end + local oldpos = pos + -- start the next search just after CRLF + pos = j + 1 + if pos >= #ret then + -- We consumed the whole thing! Start calling buffer() next. + usebuf = true + end + return ret:sub(oldpos, i - 1) + end) + return socket, code, message, buffer end --- -- Read an FTP reply and return the numeric code and the message. See RFC 959, -- section 4.2. --- @param buffer should have been created with +-- @param buffer The buffer returned by ftp.connect or created with -- stdnse.make_buffer(socket, "\r?\n"). -- @return numeric code or nil. -- @return text reply or error message. diff --git a/scripts/ftp-anon.nse b/scripts/ftp-anon.nse index 7896bd659..3ebb16606 100644 --- a/scripts/ftp-anon.nse +++ b/scripts/ftp-anon.nse @@ -5,6 +5,7 @@ local shortport = require "shortport" local stdnse = require "stdnse" local string = require "string" local table = require "table" +local sslcert = require "sslcert" description = [[ Checks if an FTP server allows anonymous logins. @@ -38,7 +39,7 @@ license = "Same as Nmap--See https://nmap.org/book/man-legal.html" categories = {"default", "auth", "safe"} -portrule = shortport.port_or_service(21, "ftp") +portrule = shortport.port_or_service({21,990}, {"ftp","ftps"}) -- --------------------- -- Directory listing function. @@ -46,12 +47,11 @@ portrule = shortport.port_or_service(21, "ftp") -- LIST on the commands socket, connect to the data one and read the directory -- list sent. -- --------------------- -local function list(socket, target, max_lines) +local function list(socket, buffer, target, max_lines) local status, err -- ask the server for a Passive Mode: it should give us a port to -- listen to, where it will dump the directory listing - local buffer = stdnse.make_buffer(socket, "\r?\n") status, err = socket:send("PASV\r\n") if not status then return status, err @@ -98,14 +98,32 @@ local function list(socket, target, max_lines) return true, listing end +local function is_ssl(socket) + return pcall(socket.get_ssl_certificate, socket) +end + +-- Try to reconnect over STARTTLS. +-- Returns a new connected socket and buffer +local function reconnect_ssl(socket, host, port) + socket:close() + local status, socket = sslcert.isPortSupported({service="ftp"})(host, port) + if status then + socket:set_timeout(8000) + return socket, stdnse.make_buffer(socket, "\r?\n") + end +end + +-- Should we try STARTTLS based on this error? +local function should_try_ssl(code, message) + return code and code >= 400 and ( + message:match('[Ss][Ss][Ll]') or + message:match('[Tt][Ll][Ss]') or + message:match('[Ss][Ee][Cc][Uu][Rr]') + ) +end + --- Connects to the FTP server and checks if the server allows anonymous logins. action = function(host, port) - local socket = nmap.new_socket() - local code, message - local err_catch = function() - socket:close() - end - local max_list = stdnse.get_script_args("ftp-anon.maxlist") if not max_list then if nmap.verbosity() == 0 then @@ -120,13 +138,20 @@ action = function(host, port) end end - local try = nmap.new_try(err_catch) - try(socket:connect(host, port)) - local buffer = stdnse.make_buffer(socket, "\r?\n") + local socket, code, message, buffer = ftp.connect(host, port, {request_timeout=8000}) + if not socket then + stdnse.debug1("Couldn't connect: %s", code) + return nil + end + + local try = nmap.new_try( function() + socket:close() + end) -- Read banner. - code, message = ftp.read_reply(buffer) + ::TRY_AGAIN:: + local already_ssl = is_ssl(socket) if code and code == 220 then try(socket:send("USER anonymous\r\n")) code, message = ftp.read_reply(buffer) @@ -134,6 +159,13 @@ action = function(host, port) -- 331: User name okay, need password. try(socket:send("PASS IEUser@\r\n")) code, message = ftp.read_reply(buffer) + elseif not already_ssl and should_try_ssl(code, message) then + socket, buffer = reconnect_ssl(socket, host, port) + if not socket then + return nil + end + code = 220 + goto TRY_AGAIN end if code == 332 then @@ -147,6 +179,13 @@ action = function(host, port) -- 331: User name okay, need password. try(socket:send("PASS IEUser@\r\n")) code, message = ftp.read_reply(buffer) + elseif not already_ssl and should_try_ssl(code, message) then + socket, buffer = reconnect_ssl(socket, host, port) + if not socket then + return nil + end + code = 220 + goto TRY_AGAIN end end end @@ -156,12 +195,20 @@ action = function(host, port) else if not code then stdnse.debug1("got socket error %q.", message) + elseif not already_ssl and should_try_ssl(code, message) then + socket, buffer = reconnect_ssl(socket, host, port) + if not socket then + return nil + end + code = 220 + goto TRY_AGAIN elseif code == 421 or code == 530 then -- Don't log known error codes. -- 421: Service not available, closing control connection. -- 530: Not logged in. else stdnse.debug1("got code %d %q.", code, message) + return ("got code %d %q."):format(code, message) end return nil end @@ -170,7 +217,7 @@ action = function(host, port) result[#result + 1] = "Anonymous FTP login allowed (FTP code " .. code .. ")" if not max_list or max_list > 0 then - local status, listing = list(socket, host, max_list) + local status, listing = list(socket, buffer, host, max_list) socket:close() if not status then diff --git a/scripts/ftp-vsftpd-backdoor.nse b/scripts/ftp-vsftpd-backdoor.nse index 1cd495bfa..de897e08e 100644 --- a/scripts/ftp-vsftpd-backdoor.nse +++ b/scripts/ftp-vsftpd-backdoor.nse @@ -158,17 +158,14 @@ vsFTPd version 2.3.4 backdoor, this was reported on 2011-07-04.]], end -- Create socket. - local sock, err = ftp.connect(host, port, - {recv_before = false, - timeout = 8000}) + local sock, code, message, buffer = ftp.connect(host, port, + {request_timeout = 8000}) if not sock then - stdnse.debug1("can't connect: %s", err) + stdnse.debug1("can't connect: %s", code) return nil end -- Read banner. - local buffer = stdnse.make_buffer(sock, "\r?\n") - local code, message = ftp.read_reply(buffer) if not code then stdnse.debug1("can't read banner: %s", message) sock:close() diff --git a/scripts/ftp-vuln-cve2010-4221.nse b/scripts/ftp-vuln-cve2010-4221.nse index b117cb105..c3cbb7447 100644 --- a/scripts/ftp-vuln-cve2010-4221.nse +++ b/scripts/ftp-vuln-cve2010-4221.nse @@ -57,10 +57,9 @@ portrule = function (host, port) return shortport.port_or_service(21, "ftp")(host, port) end -local function get_proftpd_banner(response) - local banner, version - banner = response:match("^%d+%s(.*)") - if banner and banner:match("ProFTPD") then +local function get_proftpd_banner(banner) + local version + if banner then version = banner:match("ProFTPD%s([%w%.]+)%s") end return banner, version @@ -129,13 +128,12 @@ end local function check_proftpd(ftp_opts) local ftp_server = {} - local socket, ret = ftp.connect(ftp_opts.host, ftp_opts.port, - {recv_before = true}) + local socket, code, message = ftp.connect(ftp_opts.host, ftp_opts.port) if not socket then - return socket, ret + return socket, code end - ftp_server.banner, ftp_server.version = get_proftpd_banner(ret) + ftp_server.banner, ftp_server.version = get_proftpd_banner(message) if not ftp_server.banner then return ftp_finish(socket, false, "failed to get FTP banner.") elseif not ftp_server.banner:match("ProFTPD") then