summaryrefslogtreecommitdiffstats
path: root/nselib/ftp.lua
blob: fc4d0519d7b9e46db7ada79d2b7b7e295f374114 (plain)
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
---
-- FTP functions.
--
-- @copyright Same as Nmap--See https://nmap.org/book/man-legal.html

local comm = require "comm"
local stdnse = require "stdnse"
local string = require "string"
local ipOps = require "ipOps"
local nmap = require "nmap"
_ENV = stdnse.module("ftp", stdnse.seeall)

local ERROR_MESSAGES = {
  ["EOF"]     = "connection closed",
  ["TIMEOUT"] = "connection timeout",
  ["ERROR"]   = "failed to receive data"
}

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, from comm.lua.
-- @return socket The socket descriptor, or nil on errors
-- @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)
  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
  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 The buffer returned by ftp.connect or created with
-- <code>stdnse.make_buffer(socket, "\r?\n")</code>.
-- @return numeric code or <code>nil</code>.
-- @return text reply or error message.
function read_reply(buffer)
  local readline
  local line, err
  local code, message
  local _, p, tmp

  line, err = buffer()
  if not line then
    return line, err
  end

  -- Single-line response?
  code, message = string.match(line, "^(%d%d%d) (.*)$")
  if code then
    return tonumber(code), message
  end

  -- Multi-line response?
  _, p, code, message = string.find(line, "^(%d%d%d)%-(.*)$")
  if p then
    while true do
      line, err = buffer()
      if not line then
        return line, err
      end
      tmp = string.match(line, "^%d%d%d (.*)$")
      if tmp then
        message = message .. "\n" .. tmp
        break
      end
      message = message .. "\n" .. line
    end

    return tonumber(code), message
  end

  return nil, string.format("Unparseable response: %q", line)
end

--- Close an FTP command connection
--
-- @param socket The socket with the live connection
function close(socket)
  socket:send("QUIT\r\n")
  socket:close()
end

--- Issue a STARTTLS command.
--
-- @param socket The connected command socket
-- @param buffer The socket read buffer
-- @return Boolean true if AUTH TLS succeeded, false otherwise
-- @return error string on failure
function starttls(socket, buffer)
  -- Send AUTH TLS command, ask the service to start encryption
  local status, err = socket:send("AUTH TLS\r\n")
  if not status then
    return nil, err
  end
  local code, result = read_reply(buffer)
  return code == 234, result
end

local function is_ssl(socket)
  return pcall(socket.get_ssl_certificate, socket)
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

-- Try to reconnect over STARTTLS.
local function reconnect_ssl(socket, buffer)
  local status, err = starttls(socket, buffer)
  if status then
    status, err = socket:reconnect_ssl()
    if status then
      return true
    end
  end
  return nil, err
end

--- Authenticate with username and password
--
-- May negotiate AUTH TLS if required
-- @param socket The connected command socket
-- @param buffer The socket read buffer
-- @param username The username to send
-- @param password The password to send
-- @param acct (optional) If the server requires it, send this account name. Default: username
-- @return Boolean true if auth succeeded, false otherwise
-- @return FTP response code
-- @return FTP response message
function auth(socket, buffer, username, password, acct)
  local already_ssl = is_ssl(socket)
  ::TRY_AGAIN::
  local status, err = socket:send(("USER %s\r\n"):format(username))
  if not status then
    return nil, err
  end
  local code, message = read_reply(buffer)
  if code == 331 then
    -- 331: User name okay, need password.
    status, err =socket:send(("PASS %s\r\n"):format(password))
    if not status then
      return nil, err
    end
    code, message = read_reply(buffer)
  elseif not already_ssl and should_try_ssl(code, message) then
    if not reconnect_ssl(socket, buffer) then
      return nil
    end
    already_ssl = true
    goto TRY_AGAIN
  end

  if code == 332 then
    -- 332: Need account for login.
    -- This is rarely seen but may come in response to a
    -- USER or PASS command.
    status, err = socket:send("ACCT %s\r\n"):format(acct or username)
    if not status then
      return nil, err
    end
    code, message = read_reply(buffer)
    if code == 331 then
      -- 331: User name okay, need password.
      status, err = socket:send("PASS %s\r\n"):format(password)
      if not status then
        return nil, err
      end
      code, message = read_reply(buffer)
    elseif not already_ssl and should_try_ssl(code, message) then
      if not reconnect_ssl(socket, buffer) then
        return nil
      end
      already_ssl = true
      goto TRY_AGAIN
    end
  end

  if code and code >= 200 and code < 300 then
    -- We are primarily looking for 230: User logged in, proceed.
    return true, code, message
  else
    if code and not already_ssl and should_try_ssl(code, message) then
      if not reconnect_ssl(socket, buffer) then
        return nil
      end
      already_ssl = true
      goto TRY_AGAIN
    end
    return nil, code, message
  end
end

--- Start PASV mode
--
-- For IPv6 connections, attempts to use EPSV (RFC 2428). If the server sends an address that is not the target address, then this is an error.
-- @param socket The connected command socket
-- @param buffer The receive buffer
-- @return The connected data socket, or nil on error
-- @return Error message if data socket is nil
function pasv(socket, buffer)
  local epsv = false
  local status, lhost, lport, rhost, rport = socket:get_info()
  if not status then
    return nil, ("Can't determine remote host IP: %s"):format(lhost)
  end
  epsv = #ipOps.ip_to_str(rhost) > 4

  ::TRY_AGAIN::
  local cmd = epsv and "EPSV" or "PASV"
  -- ask the server for a Passive Mode: it should give us a port to
  -- listen to, where it will dump the directory listing
  local status, err = socket:send(cmd .. "\r\n")
  if not status then
    return status, err
  end
  local code, message = read_reply(buffer)

  local pasv_port
  if epsv then
    if not code then
      return nil, ("EPSV failed: socket %s"):format(message)
    elseif code ~= 229 then
      stdnse.debug2("Falling back to PASV. EPSV: %d %s", code, message)
      epsv = false
      goto TRY_AGAIN
    end
    status, pasv_port = string.match(message, "%((.)%1%1(%d+)%1%)")
    if not status then
      stdnse.debug2("Can't parse EPSV response: %s", message)
      epsv = false
      goto TRY_AGAIN
    end
  else
    if not code or code >= 300 then
      return nil, ("PASV failed: %s %s"):format(code or "socket", message)
    end
    -- Compute the PASV port as given by the server
    -- The server should answer with something like
    -- 2xx Entering Passive Mode (a,b,c,d,hp,lp)
    --                           (-- IP--,PORT)
    -- PORT is (hp x 256) + lp
    local ip, high, low = string.match(message, "%((%d+,%d+,%d+,%d+),(%d+),(%d+)%)")
    if not ip then
      return nil, string.format("Can't parse PASV response: %q", message)
    end
    ip = ip:gsub(",", ".")
    if not (ipOps.compare_ip(ip, "eq", rhost) or ipOps.compare_ip(ip, "eq", "0.0.0.0")) then
      return nil, ("PASV IP %s is not the same as %s"):format(ip, rhost)
    end

    pasv_port = high * 256 + low
  end

  local list_socket = nmap.new_socket()
  status, err = list_socket:connect(rhost, pasv_port, "tcp")
  if not status then
    return status, err
  end

  return list_socket
end

return _ENV;