local stdnse    = require "stdnse"
local shortport = require "shortport"
local tn3270    = require "tn3270"
local brute     = require "brute"
local creds     = require "creds"
local unpwdb    = require "unpwdb"
local io = require "io"
local nmap = require "nmap"
local string = require "string"
local stringaux = require "stringaux"
local table = require "table"

description = [[
Many mainframes use VTAM screens to connect to various applications
(CICS, IMS, TSO, and many more).

This script attempts to brute force those VTAM application IDs.

This script is based on mainframe_brute by Dominic White
(https://github.com/sensepost/mainframe_brute). However, this script
doesn't rely on any third party libraries or tools and instead uses
the NSE TN3270 library which emulates a TN3270 screen in lua.

Application IDs only allows for 8 byte IDs, that is the only specific rule
found for application IDs.
]]

---
--@args idlist Path to list of application IDs to test.
--  Defaults to <code>nselib/data/vhosts-default.lst</code>.
--@args vtam-enum.commands Commands in a semi-colon separated list needed
--  to access VTAM. Defaults to <code>nothing</code>.
--@args vtam-enum.path Folder used to store valid transaction id 'screenshots'
--  Defaults to <code>None</code> and doesn't store anything.
--@args vtam-enum.macros When set to true does not prepend the application ID
--  with 'logon applid()'. Default is <code>false</code>.
--
--@usage
-- nmap --script vtam-enum -p 23 <targets>
--
-- nmap --script vtam-enum --script-args idlist=defaults.txt,
-- vtam-enum.command="exit;logon applid(logos)",vtam-enum.macros=true
-- vtam-enum.path="/home/dade/screenshots/" -p 23 -sV <targets>
--
--@output
-- PORT   STATE SERVICE VERSION
-- 23/tcp open  tn3270  IBM Telnet TN3270
-- | vtam-enum:
-- |   VTAM Application ID:
-- |     applid:TSO - Valid credentials
-- |     applid:CICSTS51 - Valid credentials
-- |_  Statistics: Performed 14 guesses in 5 seconds, average tps: 2
--
-- @changelog
-- 2015-07-04 - v0.1 - created by Soldier of Fortran
-- 2015-11-04 - v0.2 - significant upgrades and speed increases
-- 2015-11-14 - v0.3 - rewrote iterator
-- 2017-01-13 - v0.4 - Fixed 'macros' bug with default vtam screen and test
--                     and added threshold for macros screen checking
-- 2019-02-01 - v0.5 - Disabling Enhanced mode

author = "Philip Young aka Soldier of Fortran"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = {"intrusive", "brute"}

portrule = shortport.port_or_service({23,992}, "tn3270")

--- Saves the Screen generated by the VTAM command to disk
--
-- @param filename string containing the name and full path to the file
-- @param data contains the data
-- @return status true on success, false on failure
-- @return err string containing error message if status is false
local function save_screens( filename, data )
  local f = io.open( filename, "w")
  if not f then return false, ("Failed to open file (%s)"):format(filename) end
  if not(f:write(data)) then return false, ("Failed to write file (%s)"):format(filename) end
  f:close()
  return true
end

--- Compares two screens and returns the difference as a percentage
--
-- @param1 the original screen
-- @param2 the screen to compare to
local function screen_diff( orig_screen, current_screen )
  if orig_screen == current_screen then return 100 end
  if #orig_screen == 0 or #current_screen == 0 then return 0 end
  local m = 1
  for i =1 , #orig_screen do
    if orig_screen:byte(i) == current_screen:byte(i) then
      m = m + 1
    end
  end
  return (m/1920)*100
end

Driver = {
  new = function(self, host, port, options)
    local o = {}
    setmetatable(o, self)
    self.__index = self
    o.host = host
    o.port = port
    o.options = options
    o.tn3270 = tn3270.Telnet:new()
    o.tn3270:disable_tn3270e()
    return o
  end,
  connect = function( self )
    local status, err = self.tn3270:initiate(self.host,self.port)
    if not status then
      stdnse.debug2("Could not initiate TN3270: %s", err )
      return false
    end
    return true
  end,
  disconnect = function( self )
    self.tn3270:disconnect()
    self.tn3270 = nil
  end,
  login = function (self, user, pass) -- pass is actually the username we want to try
    local path = self.options['key2']
    local macros = self.options['key3']
    local cmdfmt = "logon applid(%s)"
    local type = "applid"
    local threshold = 75
    -- instead of sending 'logon applid(<appname>)' when macros=true
    -- we try to logon with just the command
    if macros then
      cmdfmt = "%s"
      type ="macro"
      threshold = 90 -- sometimes the screen barely changes
    end
    stdnse.verbose(2,"Trying VTAM ID: %s", pass)

    local previous_screen = self.tn3270:get_screen_raw()
    self.tn3270:send_cursor(cmdfmt:format(pass))
    self.tn3270:get_all_data()
    self.tn3270:get_screen_debug(2)
    local current_screen = self.tn3270:get_screen_raw()

    if (self.tn3270:find('UNABLE TO ESTABLISH SESSION')  or -- thanks goes to Dominic White for creating these
        self.tn3270:find('COMMAND UNRECOGNI[SZ]ED')      or
        self.tn3270:find('USSMSG0[1-4]')                 or
        self.tn3270:find('SESSION NOT BOUND')            or
        self.tn3270:find('INVALID COMMAND')              or
        self.tn3270:find('PARAMETER OMITTED')            or
        self.tn3270:find('REQUERIDO PARAMETRO PERDIDO')  or
        self.tn3270:find('Your command is unrecognized') or
        self.tn3270:find('invalid command or syntax')    or
        self.tn3270:find('UNSUPPORTED FUNCTION')         or
        self.tn3270:find('REQSESS error')                or
        self.tn3270:find('syntax invalid')               or
        self.tn3270:find('INVALID SYSTEM')               or
        self.tn3270:find('NOT VALID')                    or
        self.tn3270:find('INVALID USERID, APPLID') )     or
        self.tn3270:find('UNABLE TO CONNECT TO THE REQUESTED APPLICATION') or
        screen_diff(previous_screen, current_screen) > threshold then
      -- Looks like an invalid APPLID.
      stdnse.verbose(2,'Invalid Application ID: %s',string.upper(pass))
      return false,  brute.Error:new( "Invalid VTAM Application ID" )
    else
      stdnse.verbose(2,"Valid Application ID: %s",string.upper(pass))
      if path ~= nil then
        stdnse.verbose(2,"Writting screen to: %s", path..string.upper(pass)..".txt")
        local status, err = save_screens(path..string.upper(pass)..".txt",self.tn3270:get_screen())
        if not status then
          stdnse.verbose(2,"Failed writting screen to: %s", path..string.upper(pass)..".txt")
        end
      end
      return true, creds.Account:new(type,string.upper(pass), creds.State.VALID)
    end
  end
}

--- Tests the target to see if we can use logon applid(<id>) for enumeration
--
-- @param host host NSE object
-- @param port port NSE object
-- @param commands optional script-args of commands to use to get to VTAM
-- @return status true on success, false on failure
local function vtam_test( host, port, commands, macros)
  local tn = tn3270.Telnet:new()
  tn:disable_tn3270e()
  local status, err = tn:initiate(host,port)
  stdnse.debug1("Testing if VTAM and 'logon applid' command supported")
  stdnse.debug2("Connecting TN3270 to %s:%s", host.targetname or host.ip, port.number)

  if not status then
    stdnse.debug1("Could not initiate TN3270: %s", err )
    return false
  end

  stdnse.debug2("Displaying initial TN3270 Screen:")
  tn:get_screen_debug(2) -- prints TN3270 screen to debug

  if commands ~= nil then
    local run = stringaux.strsplit(";%s*", commands)
    for i = 1, #run do
      stdnse.debug(2,"Issuing Command (#%s of %s) or %s", i, #run ,run[i])
      tn:send_cursor(run[i])
      tn:get_screen_debug(2)
    end
  end
  stdnse.debug2("Sending VTAM command: IBMTEST")
  tn:send_cursor('IBMTEST')
  tn:get_all_data()
  tn:get_screen_debug(2)
  local isVTAM = false
  if not macros and tn:find('IBMECHO ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') then
    stdnse.debug2("IBMTEST Returned: IBMECHO ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.")
    stdnse.debug1("VTAM Test Success!")
    isVTAM = true
  elseif macros then
    isVTAM = true
  end

  if not macros then
    -- now testing if we can send 'logon applid(<id>)'
    -- certain systems interpret 'logon' as the tso logon
    tn:send_cursor('LOGON APPLID(FAKE)')
    tn:get_all_data()
    tn:get_screen_debug(2)
    if tn:find('INVALID USERID') then
      isVTAM = false
    end
    tn:disconnect()
  end
  return isVTAM
end

-- Checks if it's a valid VTAM name
local valid_vtam = function(x)
  return (string.len(x) <= 8 and string.match(x,"[%w@#%$]"))
end

function iter(t)
  local i, val
  return function()
    i, val = next(t, i)
    return val
  end
end

action = function(host, port)
  local vtam_id_file = stdnse.get_script_args("idlist")
  local path = stdnse.get_script_args(SCRIPT_NAME .. '.path') -- Folder for screen grabs
  local macros = stdnse.get_script_args(SCRIPT_NAME .. '.macros') or false -- if set to true, doesn't prepend the commands with 'logon applid'
  local commands = stdnse.get_script_args(SCRIPT_NAME .. '.commands') -- Commands to send to get to VTAM
  local vtam_ids = {"tso", "CICS", "IMS", "NETVIEW", "TPX"} -- these are defaults usually seen
  vtam_id_file = ( (vtam_id_file and nmap.fetchfile(vtam_id_file)) or vtam_id_file ) or
  nmap.fetchfile("nselib/data/vhosts-default.lst")

  for l in io.lines(vtam_id_file) do
    local cleaned_line = string.gsub(l,"[\r\n]","")
    if not cleaned_line:match("#!comment:") then
      table.insert(vtam_ids, cleaned_line)
    end
  end

  if vtam_test(host, port, commands, macros) then
    local options = { key1 = commands, key2 = path, key3=macros }
    stdnse.verbose("Starting VTAM Application ID Enumeration")
    if path ~= nil then stdnse.verbose(2,"Saving Screenshots to: %s", path) end
    local engine = brute.Engine:new(Driver, host, port, options)
    engine.options.script_name = SCRIPT_NAME
    engine:setPasswordIterator(unpwdb.filter_iterator(iter(vtam_ids), valid_vtam))
    engine.options.passonly = true
    engine.options:setTitle("VTAM Application ID")
    local status, result = engine:start()
    return result
  else
    return "Not VTAM or 'logon applid' command not accepted. Try with script arg 'vtam-enum.macros=true'"
  end

end
