[NSE, Wireshark] ftp-anon.nse 스크립트 분석 및 네트워크 패킷 분석
해당 스크립트는 FTP 서버에 익명 로그인이 가능한지 확인하고, 만약 가능하다면, 루트 디렉토리의 목록을 가져오고, 쓰기 권한이 부여되어 있는 파일에는 하이라이트 처리를 합니다.
전체적인 흐름
- FTP 서버에 연결 시도
- ftp.connect(host, port, {request_timeout=8000})을 사용하여 FTP 서버에 연결을 시도합니다.
- 연결이 성공하면 제어 소켓(socket), 응답 코드(code), 메시지(message), 버퍼(buffer)를 반환합니다.
- 실패 시 디버그 메시지를 기록하고
nil
을 반환합니다.
- 초기 응답 코드 확인
- 서버의 응답 코드가
220
(서비스 준비 완료)인지 확인합니다. - 220이 아닌 경우 디버그 메시지를 기록하고
nil
을 반환합니다.
- 익명 로그인 시도
ftp.auth(socket, buffer, "anonymous", "IEUser@")
을 사용하여 익명 로그인을 시도합니다.- 성공 시
status
는true
, 실패 시false
를 반환합니다. - 실패 시 소켓 오류, 코드 421(서비스 이용 불가), 코드 530(로그인 실패) 등을 처리하고, 필요 시 디버그 메시지를 기록하며
nil
을 반환합니다.
- 로그인 성공 메시지 저장
- 익명 로그인이 성공한 경우
result
테이블에 성공 메시지를 저장합니다.
- 디렉토리 목록 가져오기
- max_list 설정을 확인하고,
max_list
가nil
이거나 0보다 큰 경우 디렉토리 목록을 가져옵니다. list(socket, buffer, host, max_list)
함수를 호출하여 디렉토리 목록을 가져오고, 연결을 닫습니다.- 디렉토리 목록 가져오기에 실패한 경우 오류 메시지를
result
테이블에 저장합니다.
- 디렉토리 목록 처리
- 디렉토리 목록을 순회하며 쓰기 가능한 파일을 찾고,
[NSE: writeable]
을 표시합니다. max_list
와listing
길이가 같으면 추가적인 목록이 있음을 알리는 메시지를 저장합니다.
- 결과 반환
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
디렉터리 목록을 가져오는 함수를 설정합니다. 이 함수는 다음과 같은 역할을 합니다.
- PASV 모드 설정:
local list_socket, err = ftp.pasv(socket, buffer)
if not list_socket then
return nil, err
end
FTP서버와 PASV 모드로 데이터 연결을 설정하고, 디렉토리 목록을 받아 올 list_socket
을 생성합니다.
- LIST 명령 전송:
local status, err = socket:send("LIST\r\n")
if not status then
return status, err
end
socket:send("LIST\r\n")
명령을 통해 서버에 디렉토리 목록을 요청합니다.
- 디렉토리 목록 수신:
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_lines
가 nil
이거나, 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 연결을 시도합니다.socket
이nil
이 아니고,code
가 220이어야 이후의 코드가 작동합니다.socket
이nil
인 경우 또는 응답 코드가 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 서버에 로그인 시도합니다.status
가false
인 경우, 다음과 같은 오류 처리 과정을 거칩니다:code
가nil
인 경우, 소켓 오류로 간주하고 디버그 메시지를 기록합니다.code
가421
(서비스 이용 불가) 또는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_list
가nil
이거나 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