local coroutine = require "coroutine"
local creds = require "creds"
local io = require "io"
local nmap = require "nmap"
local packet = require "packet"
local shortport = require "shortport"
local snmp = require "snmp"
local stdnse = require "stdnse"
local unpwdb = require "unpwdb"

description = [[
Attempts to find an SNMP community string by brute force guessing.

This script opens a sending socket and a sniffing pcap socket in parallel
threads. The sending socket sends the SNMP probes with the community strings,
while the pcap socket sniffs the network for an answer to the probes. If
valid community strings are found, they are added to the creds database and
reported in the output.

The script takes the <code>snmp-brute.communitiesdb</code> argument that
allows the user to define the file that contains the community strings to
be used. If not defined, the default wordlist used to bruteforce the SNMP
community strings is <code>nselib/data/snmpcommunities.lst</code>. In case
this wordlist does not exist, the script falls back to
<code>nselib/data/passwords.lst</code>

No output is reported if no valid account is found.
]]
-- 2008-07-03 Philip Pickering, basic version
-- 2011-07-17 Gorjan Petrovski, Patrik Karlsson, optimization and creds
--            accounts, rejected use of the brute library because of
--            implementation using unconnected sockets.
-- 2011-12-29 Patrik Karlsson - Added lport to sniff_snmp_responses to fix
--                              bug preventing multiple scripts from working
--                              properly.
-- 2015-05-31 Gioacchino Mazzurco - Add IPv6 support by making the script IP
--                              version agnostic

---
-- @usage
-- nmap -sU --script snmp-brute <target> [--script-args snmp-brute.communitiesdb=<wordlist> ]
--
-- @args snmp-brute.communitiesdb The filename of a list of community strings to try.
--
-- @output
-- PORT    STATE SERVICE
-- 161/udp open  snmp
-- | snmp-brute:
-- |   dragon - Valid credentials
-- |_  jordan - Valid credentials

author = {"Philip Pickering", "Gorjan Petrovski", "Patrik Karlsson", "Gioacchino Mazzurco"}

license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

categories = {"intrusive", "brute"}


portrule = shortport.port_or_service(161, "snmp", "udp", {"open", "open|filtered"})

local communitiestable = {}

local filltable = function(filename, table)
  if #table ~= 0 then
    return true
  end

  local file = io.open(filename, "r")

  if not file then
    return false
  end

  for l in file:lines() do
    -- Comments takes up a whole line
    if not l:match("#!comment:") then
      table[#table + 1] = l
    end
  end

  file:close()

  return true
end

local closure = function(table)
  local i = 1

  return function(cmd)
    if cmd == "reset" then
      i = 1
      return
    end
    local elem = table[i]
    if elem then i = i + 1 end
    return elem
  end
end

local communities_raw = function(path)
  if not path then
    return false, "Cannot find communities list"
  end

  if not filltable(path, communitiestable) then
    return false, "Error parsing communities list"
  end

  return true, closure(communitiestable)
end

local communities = function()
  local communities_file = stdnse.get_script_args('snmp-brute.communitiesdb') or
  nmap.fetchfile("nselib/data/snmpcommunities.lst")

  if communities_file then
    stdnse.debug1("Using the %s as the communities file", communities_file)

    local status, iterator = communities_raw(communities_file)

    if not status then
      return false, iterator
    end

    local time_limit = unpwdb.timelimit()
    local count_limit = 0

    if stdnse.get_script_args("unpwdb.passlimit") then
      count_limit = tonumber(stdnse.get_script_args("unpwdb.passlimit"))
    end

    return true, unpwdb.limited_iterator(iterator, time_limit, count_limit, "communities")
  else
    stdnse.debug1("Cannot read the communities file, using the nmap username/password database instead")

    return unpwdb.passwords()
  end
end

local send_snmp_queries = function(socket, result, nextcommunity)
  local condvar = nmap.condvar(result)

  local request = snmp.buildGetRequest({}, "1.3.6.1.2.1.1.3.0")

  local payload, status, response, err
  local community = nextcommunity()

  while community do
    if result.status == false then
      --in case the sniff_snmp_responses thread was shut down
      condvar("signal")
      return
    end
    payload = snmp.encode(snmp.buildPacket(request, nil, community))
    status, err = socket:send(payload)
    if not status then
      result.status = false
      result.msg = "Could not send SNMP probe"
      condvar "signal"
      return
    end

    community = nextcommunity()
  end

  result.sent = true
  condvar("signal")
end

local sniff_snmp_responses = function(host, port, lport, result)
  local condvar = nmap.condvar(result)
  local pcap = nmap.new_socket()
  pcap:set_timeout(host.times.timeout * 1000 * 3)
  pcap:pcap_open(host.interface, 300, false,
    ("src host %s and udp and src port %d and dst port %d"):format(host.ip, port.number, lport))

  local communities = creds.Credentials:new(SCRIPT_NAME, host, port)

  -- last_run indicated whether there will be only one more receive
  local last_run = false

  -- receive even when status=false until all the probes are sent
  while true do
    if coroutine.status(result.main_thread) == "dead" then
      -- Oops, main thread quit. Time to bail.
      return
    end

    local status, plen, l2, l3, _ = pcap:pcap_receive()

    if status then
      local p = packet.Packet:new(l3,#l3)
      if not p or not p:udp_parse() then
        --shouldn't happen
        result.status = false
        result.msg = "Wrong type of packet received"
        condvar "signal"
        return
      end

      local response = p:raw(p.udp_offset + 8, #p.buf)
      local res = snmp.decode(response)

      if type(res) == "table" then
        communities:add(nil, res[2], creds.State.VALID)
      else
        result.status = false
        result.msg = "Wrong type of SNMP response received"
        condvar "signal"
        return
      end
    else
      if last_run or not result.status then
        condvar "signal"
        return
      else
        if result.sent then
          last_run = true
        end
      end
    end
  end
  pcap:close()
  condvar "signal"
  return
end

local function fail (err) return stdnse.format_output(false, err) end

action = function(host, port)
  local status, nextcommunity = communities()

  if not status then
    return fail("Failed to read the communities database")
  end

  local result = {}
  local threads = {}

  local condvar = nmap.condvar(result)

  result.sent = false --whether the probes are sent
  result.msg = "" -- Error/Status msg
  result.status = true -- Status (is everything ok)
  result.main_thread = coroutine.running() -- to check if the main thread is dead.

  local socket = nmap.new_socket("udp")
  status = socket:connect(host, port)

  if not status then
    return fail("Failed to connect to server")
  end

  local status, _, lport = socket:get_info()
  if not status then
    return fail("Failed to retrieve local port")
  end

  local recv_co = stdnse.new_thread(sniff_snmp_responses, host, port, lport, result)
  local send_co = stdnse.new_thread(send_snmp_queries, socket, result, nextcommunity)

  local recv_dead, send_dead
  while true do
    condvar "wait"
    recv_dead = (coroutine.status(recv_co) == "dead")
    send_dead = (coroutine.status(send_co) == "dead")
    if send_dead then result.sent = true end
    if recv_dead then break end
  end

  socket:close()

  if result.status then
    return creds.Credentials:new(SCRIPT_NAME, host, port):getTable()
  else
    stdnse.debug1("An error occurred: "..result.msg)
  end
end
