InfoSec Log/NSE

[NSE, Wireshark] ftp-anon.nse 스크립트 분석 및 네트워크 패킷 분석

GuanJoer 2024. 7. 1. 00:45

 

해당 스크립트는 FTP 서버에 익명 로그인이 가능한지 확인하고, 만약 가능하다면, 루트 디렉토리의 목록을 가져오고, 쓰기 권한이 부여되어 있는 파일에는 하이라이트 처리를 합니다.

 

전체적인 흐름

 

  1. FTP 서버에 연결 시도
  • ftp.connect(host, port, {request_timeout=8000})을 사용하여 FTP 서버에 연결을 시도합니다.
  • 연결이 성공하면 제어 소켓(socket), 응답 코드(code), 메시지(message), 버퍼(buffer)를 반환합니다.
  • 실패 시 디버그 메시지를 기록하고 nil을 반환합니다.
  1. 초기 응답 코드 확인
  • 서버의 응답 코드가 220(서비스 준비 완료)인지 확인합니다.
  • 220이 아닌 경우 디버그 메시지를 기록하고 nil을 반환합니다.
  1. 익명 로그인 시도
  • ftp.auth(socket, buffer, "anonymous", "IEUser@")을 사용하여 익명 로그인을 시도합니다.
  • 성공 시 statustrue, 실패 시 false를 반환합니다.
  • 실패 시 소켓 오류, 코드 421(서비스 이용 불가), 코드 530(로그인 실패) 등을 처리하고, 필요 시 디버그 메시지를 기록하며 nil을 반환합니다.
  1. 로그인 성공 메시지 저장
  • 익명 로그인이 성공한 경우 result 테이블에 성공 메시지를 저장합니다.
  1. 디렉토리 목록 가져오기
  • max_list 설정을 확인하고, max_listnil이거나 0보다 큰 경우 디렉토리 목록을 가져옵니다.
  • list(socket, buffer, host, max_list) 함수를 호출하여 디렉토리 목록을 가져오고, 연결을 닫습니다.
  • 디렉토리 목록 가져오기에 실패한 경우 오류 메시지를 result 테이블에 저장합니다.
  1. 디렉토리 목록 처리
  • 디렉토리 목록을 순회하며 쓰기 가능한 파일을 찾고, [NSE: writeable]을 표시합니다.
  • max_listlisting 길이가 같으면 추가적인 목록이 있음을 알리는 메시지를 저장합니다.
  1. 결과 반환
  • result 테이블의 모든 항목을 개행 문자로 구분하여 하나의 문자열로 결합하고 반환합니다.

스크립트 분석

local ftp = require "ftp"
local match = require "match"
local nmap = require "nmap"
local shortport = require "shortport"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"

 

해당 스크립트에 필요한 모듈들을 가져옵니다.

  • ftp: FTP 서버와 통신을 위한 기능 제공.
  • match: 문자열 패턴 매칭 기능 제공.
  • nmap: Nmap 스크립트 엔진과 상호 작용하는 기능 제공.
  • shortport: 포트 및 서비스 매칭을 위한 기능 제공.
  • stdnse: Nmap 스크립트 작성에 유용한 다양한 도구 제공.
  • string: 문자열 조작을 위한 표준 라이브러리.
  • table: 테이블 조작을 위한 표준 라이브러리.

 

description = [[
Checks if an FTP server allows anonymous logins.

If anonymous is allowed, gets a directory listing of the root directory
and highlights writeable files.
]]

 

해당 스크립트에 대한 설명 부분을 나타냅니다.

 

FTP 서버에 익명 로그인이 가능한지 확인하고, 만약 가능하다면, 루트 디렉토리의 목록을 가져오고, 쓰기 권한이 부여되어 있는 파일에는 하이라이트 처리합니다.

 

 

categories = {"default", "auth", "safe"}

 

카테고리를 설정하는 부분입니다. 해당 스크립트는 기본, 인증, 안전 카테고리에 속합니다.

 

 

portrule = shortport.port_or_service({21,990}, {"ftp","ftps"})

 

해당 스크립트의 규칙을 설정하는 부분입니다.

ftp, ftps 서비스의 표준 포트인, 21번, 990번 포트를 기준으로 해당 스크립트 적용합니다.

 

 

local function list(socket, buffer, target, max_lines)

  local list_socket, err = ftp.pasv(socket, buffer)
  if not list_socket then
    return nil, err
  end

  -- Send the LIST command on the commands socket. "Fire and forget"; we
  -- don't need to take care of the answer on this socket.
  local status, err = socket:send("LIST\r\n")
  if not status then
    return status, err
  end

  local listing = {}
  while not max_lines or #listing < max_lines do
    local status, data = list_socket:receive_buf(match.pattern_limit("\r?\n", 2048), false)
    if (not status and data == "EOF") or data == "" then
      break
    end
    if not status then
      return status, data
    end
    listing[#listing + 1] = data
  end

  return true, listing
end

 

 

디렉터리 목록을 가져오는 함수를 설정합니다. 이 함수는 다음과 같은 역할을 합니다.

  1. PASV 모드 설정:
local list_socket, err = ftp.pasv(socket, buffer)
if not list_socket then
    return nil, err
end

 

FTP서버와 PASV 모드로 데이터 연결을 설정하고, 디렉토리 목록을 받아 올 list_socket을 생성합니다.

 

  1. LIST 명령 전송:
local status, err = socket:send("LIST\r\n")
if not status then
    return status, err
end

 

socket:send("LIST\r\n")명령을 통해 서버에 디렉토리 목록을 요청합니다.

  1. 디렉토리 목록 수신:
  local listing = {}
  while not max_lines or #listing < max_lines do
    local status, data = list_socket:receive_buf(match.pattern_limit("\r?\n", 2048), false)
    if (not status and data == "EOF") or data == "" then
      break
    end
    if not status then
      return status, data
    end
    listing[#listing + 1] = data
  end

  return true, listing

 

max_linesnil이거나, listing 테이블의 길이보다 작을 경우, "\r?\n"을 통해 줄바꿈을 기준으로 디렉터리 목록을 받아와 listing 테이블에 추가합니다. 반복이 끝난 후 listing 테이블 값을 반환합니다.

 

 

  local max_list = stdnse.get_script_args("ftp-anon.maxlist")
  if not max_list then
    if nmap.verbosity() == 0 then
      max_list = 20
    else
      max_list = nil
    end
  else
    max_list = tonumber(max_list)
    if max_list < 0 then
      max_list = nil
    end
  end

 

action 함수 내에서 ftp-anon.maxlist로 설정한 스크립트 인수가 존재할 경우, 해당 값을 max_list에 할당하여 숫자형으로 변환합니다. 만약 해당 값이 음수이면 max_list를 무한대로 설정합니다.

스크립트 인수가 설정되지 않았을 경우:

  • 기본 값으로 max_list를 20으로 설정합니다.
  • Nmap의 상세 출력 레벨이 설정(nmap.verbosity() ~= 0)된 경우, max_list를 무한대로 설정합니다.

 

  local socket, code, message, buffer = ftp.connect(host, port, {request_timeout=8000})
  if not socket then
    stdnse.debug1("Couldn't connect: %s", code or message)
    return nil
  end
  if code and code ~= 220 then
    stdnse.debug1("banner code %d %q.", code, message)
    return nil
  end

 

FTP 서버에 연결을 요청합니다.

  • ftp.connect(host, port, {request_timeout=8000}): 호스트와 포트에 FTP 연결을 시도합니다.
  • socketnil이 아니고, code220이어야 이후의 코드가 작동합니다.
  • socketnil인 경우 또는 응답 코드가 220이 아닌 경우, 디버그 메시지를 기록하고 nil을 반환합니다.

 

  local status, code, message = ftp.auth(socket, buffer, "anonymous", "IEUser@")
  if not status then
    if not code then
      stdnse.debug1("got socket error %q.", message)
    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

 

익명 로그인을 시도합니다.

  • ftp.auth(socket, buffer, "anonymous", "IEUser@"): 사용자 이름을 anonymous, 비밀번호를 IEUser@로 사용하여 FTP 서버에 로그인 시도합니다.
  • statusfalse인 경우, 다음과 같은 오류 처리 과정을 거칩니다:
    • codenil인 경우, 소켓 오류로 간주하고 디버그 메시지를 기록합니다.
    • code421(서비스 이용 불가) 또는 530(로그인 실패)인 경우, 별도의 처리 없이 넘어갑니다.
    • 그 외의 오류 코드인 경우, 디버그 메시지를 기록하고 해당 오류 메시지를 반환합니다.
  local result = {}
  result[#result + 1] = "Anonymous FTP login allowed (FTP code " .. code .. ")"

  if not max_list or max_list > 0 then
    local status, listing = list(socket, buffer, host, max_list)
    ftp.close(socket)

    if not status then
      result[#result + 1] = "Can't get directory listing: " .. listing
    else
      for _, item in ipairs(listing) do
        -- Just a quick passive check on user rights.
        if string.match(item, "^[d-].......w.") then
          item = item .. " [NSE: writeable]"
        end
        result[#result + 1] = item
      end
      if max_list and #listing == max_list then
        result[#result + 1] = string.format("Only %d shown. Use --script-args %s.maxlist=-1 to see all.", #listing, SCRIPT_NAME)
      end
    end
  end

  return table.concat(result, "\n")

 

익명 로그인이 성공한 경우, 디렉터리 목록을 가져와서 result 테이블에 저장합니다.

  • result[#result + 1]: result 테이블에 새로운 항목을 추가합니다.
  • max_listnil이거나 0보다 큰 경우, 이전에 정의한 list() 함수를 실행하여 디렉터리 목록을 가져옵니다.
  • 디렉터리 목록을 가져온 후, FTP 연결을 닫습니다.
  • 디렉터리 목록 중 쓰기 권한이 있는 항목에는 [NSE: writeable]를 추가합니다.
  • listing의 길이와 max_list의 값이 같으면, 마지막에 초과된 항목을 표시하는 메시지를 추가합니다.
  • result 테이블의 모든 항목을 개행 문자로 구분하여 하나의 문자열로 결합한 후 반환합니다.

 

실행결과

 

 

metasploitablev2(192.168.56.3)를 대상으로 해당 스크립트를 실행하여, ftp 익명 로그인 취약점 스캐닝을 한 결과, 익명 로그인이 가능함을 위 사진을 통해 볼 수 있다.(ftp code = 230)

 

즉 metasploitablev2에는 ftp 익명 로그인 취약점이 존재한다고 할 수 있다.

 

Wireshark로 네트워크 패킷 확인

 

 

처음에 공격 pc인 10.0.2.15(kali linux)가 59734포트를 이용하여, 타겟 pc의 21번 포트로 tcp 연결 수립을 하는 과정을 나타내고 있다.

 

즉 3 way handshake 과정인, SYN -> SYN, ACK ->ACK 과정이 위 사진에 나타나 있고, 서버의 응답으로 서비스의 준비가 완료되었다는, 220의 상태 코드를 클라이언트에 응답으로 보내는 것을 볼 수 있다.

 

 

FTP 서버와 연결에 성공 한 후, USER는 anonymous로, PASS는 IEUser@로 로그인을 시도하고, 서버에서 그 응답으로 230의 상태코드를 반환하여, 로그인이 성공적으로 진행된 것을 볼 수 있다.

 

 

루트 디렉토리의 목록을 받아오기 위해, ftp 서버에 PASV모드를 설정하여 요청을 보내고, 해당 요청에 대한 서버의 응답으로, 227 Entering Passive Mode를 통해 PASV 모드가 설정되었음을 알리고 있다. 

 

 

LIST를 통해, 해당 ftp 서버의 루트 디렉토리 목록을 요청하고 있고, 해당 요청에 대한 서버의 응답으로, 성공적으로 디렉토리 목록을 보내고 있음을 볼 수 있다.

 

디렉토리 목록을 응답으로 받으면, 이제 더 이상 해당 서버에게 용무가 없으므로, QUIT 요청을 보내, 서버와의 연결을 끊는다.

 

 

전체 소스 코드

local ftp = require "ftp"
local match = require "match"
local nmap = require "nmap"
local shortport = require "shortport"
local stdnse = require "stdnse"
local string = require "string"
local table = require "table"

description = [[
Checks if an FTP server allows anonymous logins.

If anonymous is allowed, gets a directory listing of the root directory
and highlights writeable files.
]]

---
-- @see ftp-brute.nse
--
-- @args ftp-anon.maxlist The maximum number of files to return in the
-- directory listing. By default it is 20, or unlimited if verbosity is
-- enabled. Use a negative number to disable the limit, or
-- <code>0</code> to disable the listing entirely.
--
-- @output
-- PORT   STATE SERVICE
-- 21/tcp open  ftp
-- | ftp-anon: Anonymous FTP login allowed (FTP code 230)
-- | -rw-r--r--   1 1170     924            31 Mar 28  2001 .banner
-- | d--x--x--x   2 root     root         1024 Jan 14  2002 bin
-- | d--x--x--x   2 root     root         1024 Aug 10  1999 etc
-- | drwxr-srwt   2 1170     924          2048 Jul 19 18:48 incoming [NSE: writeable]
-- | d--x--x--x   2 root     root         1024 Jan 14  2002 lib
-- | drwxr-sr-x   2 1170     924          1024 Aug  5  2004 pub
-- |_Only 6 shown. Use --script-args ftp-anon.maxlist=-1 to see all.

author = {"Eddie Bell", "Rob Nicholls", "Ange Gutek", "David Fifield"}
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"default", "auth", "safe"}


portrule = shortport.port_or_service({21,990}, {"ftp","ftps"})

-- ---------------------
-- Directory listing function.
-- We ask for a PASV connexion, catch the port returned by the server, send a
-- LIST on the commands socket, connect to the data one and read the directory
-- list sent.
-- ---------------------
local function list(socket, buffer, target, max_lines)

  local list_socket, err = ftp.pasv(socket, buffer)
  if not list_socket then
    return nil, err
  end

  -- Send the LIST command on the commands socket. "Fire and forget"; we
  -- don't need to take care of the answer on this socket.
  local status, err = socket:send("LIST\r\n")
  if not status then
    return status, err
  end

  local listing = {}
  while not max_lines or #listing < max_lines do
    local status, data = list_socket:receive_buf(match.pattern_limit("\r?\n", 2048), false)
    if (not status and data == "EOF") or data == "" then
      break
    end
    if not status then
      return status, data
    end
    listing[#listing + 1] = data
  end

  return true, listing
end

--- Connects to the FTP server and checks if the server allows anonymous logins.
action = function(host, port)
  local max_list = stdnse.get_script_args("ftp-anon.maxlist")
  if not max_list then
    if nmap.verbosity() == 0 then
      max_list = 20
    else
      max_list = nil
    end
  else
    max_list = tonumber(max_list)
    if max_list < 0 then
      max_list = nil
    end
  end


  local socket, code, message, buffer = ftp.connect(host, port, {request_timeout=8000})
  if not socket then
    stdnse.debug1("Couldn't connect: %s", code or message)
    return nil
  end
  if code and code ~= 220 then
    stdnse.debug1("banner code %d %q.", code, message)
    return nil
  end

  local status, code, message = ftp.auth(socket, buffer, "anonymous", "IEUser@")
  if not status then
    if not code then
      stdnse.debug1("got socket error %q.", message)
    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

  local result = {}
  result[#result + 1] = "Anonymous FTP login allowed (FTP code " .. code .. ")"

  if not max_list or max_list > 0 then
    local status, listing = list(socket, buffer, host, max_list)
    ftp.close(socket)

    if not status then
      result[#result + 1] = "Can't get directory listing: " .. listing
    else
      for _, item in ipairs(listing) do
        -- Just a quick passive check on user rights.
        if string.match(item, "^[d-].......w.") then
          item = item .. " [NSE: writeable]"
        end
        result[#result + 1] = item
      end
      if max_list and #listing == max_list then
        result[#result + 1] = string.format("Only %d shown. Use --script-args %s.maxlist=-1 to see all.", #listing, SCRIPT_NAME)
      end
    end
  end

  return table.concat(result, "\n")
end