nselib/pop3: harden login helpers and fix CAPA parsing

- Keep err table module-level for pop3.err.* API compatibility
- stat() now returns explicit boolean with ~= nil
- SASL LOGIN validates server continuation (+) before base64 decode
- CRAM-MD5 validates server continuation (+) before base64 decode
- APOP pattern tightened to RFC 1939 format <id@host>
- Normalize line endings for CRLF and LF-only servers
- Add nil guard before capas[name] indexing
- Move OpenSSL checks inline per function
- Complete @param/@return doc blocks on all functions
This commit is contained in:
Sweekar-cmd 2026-03-07 23:33:26 +05:45
parent 034ce94a1e
commit 7e7a33476b
2 changed files with 296 additions and 96 deletions

View file

@ -1,8 +1,11 @@
---
-- POP3 helper functions for NSE scripts.
<<<<<<< Updated upstream
--
-- @copyright Same as Nmap
-- See https://nmap.org/book/man-legal.html
=======
>>>>>>> Stashed changes
--
local base64 = require "base64"
@ -18,6 +21,7 @@ _ENV = stdnse.module("pop3", stdnse.seeall)
local HAVE_SSL, openssl = pcall(require, "openssl")
<<<<<<< Updated upstream
-- Error codes returned by login helpers
local err = {
none = 0,
@ -25,10 +29,21 @@ local err = {
pwError = 2,
informationMissing = 3,
OpenSSLMissing = 4,
=======
-- Error codes returned by login helpers.
-- Must remain module-level so scripts can access pop3.err.*
err = {
none = 0,
userError = 1,
pwError = 2,
informationMissing = 3,
OpenSSLMissing = 4,
>>>>>>> Stashed changes
}
---
-- Check whether a POP3 response indicates success.
<<<<<<< Updated upstream
-- @param line POP3 response line
-- @return true if response starts with "+OK"
function stat(line)
@ -37,6 +52,21 @@ end
---
-- USER / PASS authentication
=======
-- @param line POP3 response line.
-- @return true if response starts with "+OK", false otherwise.
function stat(line)
return type(line) == "string" and line:match("^%+OK") ~= nil
end
---
-- USER/PASS authentication.
-- @param socket Socket connected to POP3 server.
-- @param user Username string.
-- @param pw Password string.
-- @return status true on success, false on failure.
-- @return err Error code if status is false.
>>>>>>> Stashed changes
function login_user(socket, user, pw)
socket:send("USER " .. user .. "\r\n")
local _, line = socket:receive_lines(1)
@ -50,12 +80,24 @@ function login_user(socket, user, pw)
if stat(line) then
return true, err.none
end
<<<<<<< Updated upstream
=======
>>>>>>> Stashed changes
return false, err.pwError
end
---
<<<<<<< Updated upstream
-- SASL PLAIN authentication
=======
-- SASL PLAIN authentication.
-- @param socket Socket connected to POP3 server.
-- @param user Username string.
-- @param pw Password string.
-- @return status true on success, false on failure.
-- @return err Error code if status is false.
>>>>>>> Stashed changes
function login_sasl_plain(socket, user, pw)
local auth64 = base64.enc(user .. "\0" .. user .. "\0" .. pw)
socket:send("AUTH PLAIN " .. auth64 .. "\r\n")
@ -64,16 +106,29 @@ function login_sasl_plain(socket, user, pw)
if stat(line) then
return true, err.none
end
<<<<<<< Updated upstream
=======
>>>>>>> Stashed changes
return false, err.pwError
end
---
<<<<<<< Updated upstream
-- SASL LOGIN authentication
=======
-- SASL LOGIN authentication.
-- @param socket Socket connected to POP3 server.
-- @param user Username string.
-- @param pw Password string.
-- @return status true on success, false on failure.
-- @return err Error code if status is false.
>>>>>>> Stashed changes
function login_sasl_login(socket, user, pw)
socket:send("AUTH LOGIN\r\n")
local _, line = socket:receive_lines(1)
<<<<<<< Updated upstream
local prompt = base64.dec(string.sub(line or "", 3)):lower()
if not prompt:find("user") then
@ -88,23 +143,61 @@ function login_sasl_login(socket, user, pw)
return false, err.userError
end
=======
if type(line) ~= "string" or line:sub(1, 1) ~= "+" then
return false, err.userError
end
local prompt = base64.dec(line:sub(3)):lower()
if not prompt:find("user") then
return false, err.userError
end
socket:send(base64.enc(user) .. "\r\n")
_, line = socket:receive_lines(1)
if type(line) ~= "string" or line:sub(1, 1) ~= "+" then
return false, err.userError
end
prompt = base64.dec(line:sub(3)):lower()
if not prompt:find("pass") then
return false, err.userError
end
>>>>>>> Stashed changes
socket:send(base64.enc(pw) .. "\r\n")
_, line = socket:receive_lines(1)
if stat(line) then
return true, err.none
end
<<<<<<< Updated upstream
=======
>>>>>>> Stashed changes
return false, err.pwError
end
---
<<<<<<< Updated upstream
-- APOP authentication (RFC 1939)
=======
-- APOP authentication (RFC 1939).
-- @param socket Socket connected to POP3 server.
-- @param user Username string.
-- @param pw Password string.
-- @param challenge APOP challenge string from the server greeting.
-- @return status true on success, false on failure.
-- @return err Error code if status is false.
>>>>>>> Stashed changes
function login_apop(socket, user, pw, challenge)
if not HAVE_SSL then
return false, err.OpenSSLMissing
end
<<<<<<< Updated upstream
=======
>>>>>>> Stashed changes
if type(challenge) ~= "string" then
return false, err.informationMissing
end
@ -116,12 +209,24 @@ function login_apop(socket, user, pw, challenge)
if stat(line) then
return true, err.none
end
<<<<<<< Updated upstream
=======
>>>>>>> Stashed changes
return false, err.pwError
end
---
<<<<<<< Updated upstream
-- SASL CRAM-MD5 authentication
=======
-- SASL CRAM-MD5 authentication.
-- @param socket Socket connected to POP3 server.
-- @param user Username string.
-- @param pw Password string.
-- @return status true on success, false on failure.
-- @return err Error code if status is false.
>>>>>>> Stashed changes
function login_sasl_crammd5(socket, user, pw)
if not HAVE_SSL then
return false, err.OpenSSLMissing
@ -129,10 +234,20 @@ function login_sasl_crammd5(socket, user, pw)
socket:send("AUTH CRAM-MD5\r\n")
local _, line = socket:receive_lines(1)
<<<<<<< Updated upstream
local challenge = base64.dec(string.sub(line or "", 3))
local digest = stdnse.tohex(openssl.hmac("md5", pw, challenge))
local auth = base64.enc(user .. " " .. digest)
=======
if type(line) ~= "string" or line:sub(1, 1) ~= "+" then
return false, err.pwError
end
local challenge = base64.dec(line:sub(3))
local digest = stdnse.tohex(openssl.hmac("md5", pw, challenge))
local auth = base64.enc(user .. " " .. digest)
>>>>>>> Stashed changes
socket:send(auth .. "\r\n")
_, line = socket:receive_lines(1)
@ -140,12 +255,23 @@ function login_sasl_crammd5(socket, user, pw)
if stat(line) then
return true, err.none
end
<<<<<<< Updated upstream
=======
>>>>>>> Stashed changes
return false, err.pwError
end
---
<<<<<<< Updated upstream
-- Query POP3 server capabilities (RFC 2449)
=======
-- Query POP3 server capabilities (RFC 2449).
-- @param host Host to query.
-- @param port Port to connect to.
-- @return capas Table of capabilities, or nil on error.
-- @return nil or error string on failure.
>>>>>>> Stashed changes
function capabilities(host, port)
local socket, _, _, greeting =
comm.tryssl(host, port, "", { recv_before = true })
@ -161,30 +287,58 @@ function capabilities(host, port)
local capas = {}
<<<<<<< Updated upstream
-- APOP challenge present in greeting
if greeting:find("<[^>]+>") then
=======
-- APOP challenge must match <process-ID.clock@hostname> per RFC 1939
if greeting:find("<[^>]+@[^>]+>") then
>>>>>>> Stashed changes
capas.APOP = {}
end
socket:send("CAPA\r\n")
local status, response =
socket:receive_buf(match.pattern_limit("%.\r?\n", 4096), false)
<<<<<<< Updated upstream
=======
>>>>>>> Stashed changes
socket:close()
if not status then
return nil, "Failed to receive CAPA response"
end
<<<<<<< Updated upstream
local lines = stringaux.strsplit("\r\n", response)
=======
if not status then
return nil, "Failed to receive CAPA response"
end
-- Normalize line endings to handle both CRLF and LF-only servers
response = response:gsub("\r\n", "\n")
local lines = stringaux.strsplit("\n", response)
>>>>>>> Stashed changes
if not stat(table.remove(lines, 1)) then
capas.capa = false
return capas
end
<<<<<<< Updated upstream
for _, line in ipairs(lines) do
if line and #line > 0 then
local name, args = line:match("^(%S+)%s*(.*)")
capas[name] = args ~= "" and stringaux.strsplit(" ", args) or {}
=======
for _, ln in ipairs(lines) do
if ln and #ln > 0 then
local name, args = ln:match("^(%S+)%s*(.*)")
if name then
capas[name] = args ~= "" and stringaux.strsplit(" ", args) or {}
end
>>>>>>> Stashed changes
end
end

View file

@ -1,136 +1,182 @@
local brute = require "brute"
local comm = require "comm"
local creds = require "creds"
local nmap = require "nmap"
local pop3 = require "pop3"
local brute = require "brute"
local comm = require "comm"
local creds = require "creds"
local pop3 = require "pop3"
local shortport = require "shortport"
local string = require "string"
local stdnse = require "stdnse"
description = [[
Tries to log into a POP3 account by guessing usernames and passwords.
Automatically detects supported authentication mechanisms via CAPA and
upgrades to TLS using STLS when available on port 110. Supports implicit
TLS for POP3S (port 995). The auth method can be overridden manually via
the <code>pop3loginmethod</code> script argument.
]]
---
-- @args pop3loginmethod The login method to use: <code>"USER"</code>
-- (default), <code>"SASL-PLAIN"</code>, <code>"SASL-LOGIN"</code>,
-- <code>"SASL-CRAM-MD5"</code>, or <code>"APOP"</code>. Defaults to <code>"USER"</code>,
-- @args pop3loginmethod Override automatic auth selection. Valid values:
-- <code>"USER"</code>, <code>"SASL-PLAIN"</code>,
-- <code>"SASL-LOGIN"</code>, <code>"SASL-CRAM-MD5"</code>,
-- <code>"APOP"</code>. If not set, the best method is chosen from CAPA.
--
-- @output
-- PORT STATE SERVICE
-- 110/tcp open pop3
-- | pop3-brute-ported:
-- | Accounts:
-- | user:pass => Login correct
-- | Statistics:
-- |_ Performed 8 scans in 1 seconds, average tps: 8
author = {"Philip Pickering", "Piotr Olma"}
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
-- | pop3-brute:
-- | Accounts:
-- | admin:password - Valid credentials
-- | Statistics:
-- |_ Performed 101 guesses in 22 seconds, average tps: 4.5
author = {"Philip Pickering", "Piotr Olma", "Sweekar-cmd"}
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"intrusive", "brute"}
Driver = {
new = function(self, host, port, login_function, is_apop)
local o = {}
setmetatable(o, self)
portrule = shortport.port_or_service({110, 995, 1110}, {"pop3", "pop3s"})
-- Select the best available auth method from CAPA capabilities.
-- Preference order: CRAM-MD5 > LOGIN > PLAIN > APOP > USER
local function choose_auth(capas)
local sasl = capas.SASL or capas.AUTH
if sasl then
local mechs = {}
for _, m in ipairs(sasl) do
mechs[m:upper()] = true
end
if mechs["CRAM-MD5"] then return pop3.login_sasl_crammd5, false end
if mechs["LOGIN"] then return pop3.login_sasl_login, false end
if mechs["PLAIN"] then return pop3.login_sasl_plain, false end
end
if capas.APOP then return pop3.login_apop, true end
return pop3.login_user, false
end
local Driver = {
new = function(self, host, port, opts)
local o = setmetatable({}, self)
self.__index = self
o.port = port
o.host = host
o.login_function = login_function
o.is_apop = is_apop
o.host = host
o.port = port
o.login_function = opts.login_function
o.is_apop = opts.is_apop
o.use_stls = opts.use_stls
o.implicit_tls = opts.implicit_tls
return o
end,
-- Attempts to connect to the POP server
-- @return true on success
-- @return false, brute.Error object on failure
connect = function(self)
local line
self.socket = brute.new_socket()
local opts = {timeout=10000, recv_before=true}
local best_opt, line, _
self.socket, _, best_opt, line = comm.tryssl(self.host, self.port, "" , opts)
if self.implicit_tls then
self.socket = brute.new_socket()
local ok = self.socket:connect(self.host, self.port, "ssl")
if not ok then
local e = brute.Error:new("SSL connection failed.")
e:setAbort(true)
return false, e
end
local _, resp = self.socket:receive_lines(1)
line = resp
else
self.socket, _, _, line =
comm.tryssl(self.host, self.port, "", { timeout = 10000, recv_before = true })
end
if not self.socket then
local err = brute.Error:new("Failed to connect.")
err:setAbort(true)
return false, err
end --no connection
local e = brute.Error:new("Failed to connect.")
e:setAbort(true)
return false, e
end
if not pop3.stat(line) then
local err = brute.Error:new("Failed to make a pop-connection.")
err:setAbort(true)
return false, err
end -- no pop-connection
local e = brute.Error:new("Invalid POP3 greeting.")
e:setAbort(true)
return false, e
end
if self.is_apop then
self.additional = string.match(line, "<[%p%w]+>") --apop challenge
self.additional = line:match("<[^>]+@[^>]+>")
end
return true
end, --connect
-- Attempts to login to the POP server
--
-- @param username string containing the login username
-- @param password string containing the login password
-- @return status, true on success, false on failure
-- @return brute.Error object on failure
-- creds.Account object on success
login = function(self, username, password)
local pstatus
local perror
pstatus, perror = self.login_function(self.socket, username, password, self.additional)
if pstatus then
return true, creds.Account:new(username, password, creds.State.VALID)
else
local err
if (perror == pop3.err.pwError) then
err = brute.Error:new("Wrong password.")
elseif (perror == pop3.err.userError) then
err = brute.Error:new("Wrong username.")
err:setInvalidAccount(username)
else
err = brute.Error:new("Login failed.")
if self.use_stls then
self.socket:send("STLS\r\n")
local _, resp = self.socket:receive_lines(1)
if not pop3.stat(resp) then
local e = brute.Error:new("STLS negotiation failed.")
e:setAbort(true)
return false, e
end
local ok, ssl_err = self.socket:reconnect_ssl()
if not ok then
local e = brute.Error:new("TLS handshake failed: " .. (ssl_err or "unknown"))
e:setAbort(true)
return false, e
end
return false, err
end
end, --login
return true
end,
login = function(self, username, password)
local ok, code =
self.login_function(self.socket, username, password, self.additional)
if ok then
return true, creds.Account:new(username, password, creds.State.VALID)
end
local e
if code == pop3.err.pwError then
e = brute.Error:new("Wrong password.")
elseif code == pop3.err.userError then
e = brute.Error:new("Wrong username.")
e:setInvalidAccount(username)
elseif code == pop3.err.OpenSSLMissing then
e = brute.Error:new("OpenSSL required for this authentication method.")
e:setAbort(true)
else
e = brute.Error:new("Login failed.")
end
return false, e
end,
disconnect = function(self)
self.socket:close()
end, --disconnect
if self.socket then self.socket:close() end
end,
check = function(self)
return true
end, --check
check = function(self) return true end,
}
portrule = shortport.port_or_service({110, 995}, {"pop3","pop3s"})
action = function(host, port)
local pMeth = nmap.registry.args.pop3loginmethod
if (not pMeth) then pMeth = nmap.registry.pop3loginmethod end
if (not pMeth) then pMeth = "USER" end
--determine function we will use to login to server
local is_apop = false
local login_function
if (pMeth == "USER") then
login_function = pop3.login_user
elseif (pMeth == "SASL-PLAIN") then
login_function = pop3.login_sasl_plain
elseif (pMeth == "SASL-LOGIN") then
login_function = pop3.login_sasl_login
elseif (pMeth == "SASL-CRAM-MD5") then
login_function = pop3.login_sasl_crammd5
elseif (pMeth == "APOP") then
login_function = pop3.login_apop
is_apop = true
else
login_function = pop3.login_user
local capas = pop3.capabilities(host, port)
if not capas then
return "Could not retrieve capabilities."
end
local engine = brute.Engine:new(Driver, host, port, login_function, is_apop)
local login_function, is_apop
-- Manual override takes priority over auto-detection
local pMeth = stdnse.get_script_args("pop3loginmethod")
if pMeth then
if pMeth == "SASL-PLAIN" then login_function = pop3.login_sasl_plain
elseif pMeth == "SASL-LOGIN" then login_function = pop3.login_sasl_login
elseif pMeth == "SASL-CRAM-MD5" then login_function = pop3.login_sasl_crammd5
elseif pMeth == "APOP" then login_function = pop3.login_apop; is_apop = true
else login_function = pop3.login_user
end
else
login_function, is_apop = choose_auth(capas)
end
local implicit_tls = (port.number == 995 or port.service == "pop3s")
local use_stls = (port.number == 110 and not implicit_tls and capas.STLS ~= nil)
local engine = brute.Engine:new(Driver, host, port, {
login_function = login_function,
is_apop = is_apop,
use_stls = use_stls,
implicit_tls = implicit_tls,
})
engine.options.script_name = SCRIPT_NAME
local status, accounts = engine:start()
local _, accounts = engine:start()
return accounts
end