local coroutine = require "coroutine"
local ipOps = require "ipOps"
local nmap = require "nmap"
local packet = require "packet"
local stdnse = require "stdnse"
local string = require "string"
local tab = require "tab"
local table = require "table"
local target = require "target"
local rand = require "rand"

description = [[
Performs IPv6 host discovery by triggering stateless address auto-configuration
(SLAAC).

This script works by sending an ICMPv6 Router Advertisement with a random
address prefix, which causes hosts to begin SLAAC and send a solicitation for
their newly configured address, as part of duplicate address detection. The
script then guesses the remote addresses by combining the link-local prefix of
the interface with the interface identifier in each of the received
solicitations. This should be followed up with ordinary ND host discovery to
verify that the guessed addresses are correct.

The router advertisement has a router lifetime of zero and a short prefix
lifetime (a few seconds)

See also:
* RFC 4862, IPv6 Stateless Address Autoconfiguration, especially section 5.5.3.
* https://github.com/rapid7/metasploit-framework/blob/master/modules/auxiliary/scanner/discovery/ipv6_neighbor_router_advertisement.rb
]]

---
-- @usage
-- nmap -6 --script targets-ipv6-multicast-slaac --script-args 'newtargets,interface=eth0' -sP
-- @output
-- Pre-scan script results:
-- | targets-ipv6-multicast-slaac:
-- |   IP: fe80:0000:0000:0000:1322:33ff:fe44:5566  MAC: 11:22:33:44:55:66  IFACE: eth0
-- |_  Use --script-args=newtargets to add the results as targets
-- @args targets-ipv6-multicast-slaac.interface  The interface to use for host discovery.

author = {"David Fifield", "Xu Weilin"}

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

categories = {"discovery","broadcast"}


prerule = function()
  return nmap.is_privileged()
end

local function get_identifier(ip6_addr)
  return string.sub(ip6_addr, 9, 16)
end

--- Get a Unique-local Address with random global ID.
-- @param local_scope The scope of the address, local or reserved.
-- @return A 16-byte string of IPv6 address, and the length of the prefix.
local function get_random_ula_prefix(local_scope)
  local ula_prefix
  local global_id = rand.random_string(5)

  if local_scope then
    ula_prefix = ipOps.ip_to_str("fd00::")
  else
    ula_prefix = ipOps.ip_to_str("fc00::")
  end
  ula_prefix = string.sub(ula_prefix,1,1) .. global_id .. string.sub(ula_prefix,7,-1)
  return ula_prefix,64
end

--- Build an ICMPv6 payload of Router Advertisement.
-- @param mac_src six-byte string of the source MAC address.
-- @param prefix 16-byte string of IPv6 address.
-- @param prefix_len integer that represents the length of the prefix.
-- @param valid_time integer that represents the valid time of the prefix.
-- @param preferred_time integer that represents the preferred time of the prefix.
local function build_router_advert(mac_src,prefix,prefix_len,valid_time,preferred_time)
  local ra_msg = string.char(0x0, --cur hop limit
  0x08, --flags
  0x00,0x00, --router lifetime
  0x00,0x00,0x00,0x00, --reachable time
  0x00,0x00,0x00,0x00) --retrans timer
  local prefix_option_msg = string.pack(">BB I4 I4 I4",
    prefix_len,
    0xc0, --flags: Onlink, Auto
    valid_time, -- valid lifetime
    preferred_time, -- preferred lifetime
    0 -- unknown
    ) .. prefix
  local icmpv6_prefix_option = packet.Packet:set_icmpv6_option(packet.ND_OPT_PREFIX_INFORMATION,prefix_option_msg)
  local icmpv6_src_link_option = packet.Packet:set_icmpv6_option(packet.ND_OPT_SOURCE_LINKADDR,mac_src)
  local icmpv6_payload = ra_msg .. icmpv6_prefix_option .. icmpv6_src_link_option
  return icmpv6_payload
end

local function filter_interfaces(if_table)
  local addr = ipOps.ip_to_str(if_table.address)
  if addr and #addr == 16 and if_table.link == "ethernet" then
    return if_table
  end
end

local function single_interface_broadcast(if_nfo, results)
  stdnse.debug1("Starting " .. SCRIPT_NAME .. " on " .. if_nfo.device)

  local condvar = nmap.condvar(results)
  local src_mac = if_nfo.mac
  local src_ip6 = ipOps.ip_to_str(if_nfo.address)
  local dst_mac = packet.mactobin("33:33:00:00:00:01")
  local dst_ip6 = ipOps.ip_to_str("ff02::1")

  ----------------------------------------------------------------------------
  --SLAAC-based host discovery probe

  local dnet = nmap.new_dnet()
  local pcap = nmap.new_socket()

  local function catch ()
    dnet:ethernet_close()
    pcap:pcap_close()
  end
  local try = nmap.new_try(catch)

  try(dnet:ethernet_open(if_nfo.device))
  pcap:pcap_open(if_nfo.device, 128, true, "src ::0/128 and dst net ff02::1:0:0/96 and icmp6 and ip6[6:1] = 58 and ip6[40:1] = 135")

  local actual_prefix = string.sub(src_ip6,1,8)
  local ula_prefix, prefix_len = get_random_ula_prefix()

  -- preferred_lifetime <= valid_lifetime.
  -- Nmap will get the whole IPv6 addresses of each host if the two parameters are both longer than 5 seconds.
  -- Sometimes it makes sense to regard the several addresses of a host as
  -- different hosts, as the host's administrator may apply different firewall
  -- configurations on them.
  local valid_lifetime = 6
  local preferred_lifetime = 6

  local probe = packet.Frame:new()

  probe.ip_bin_src = packet.mac_to_lladdr(src_mac)
  probe.ip_bin_dst = dst_ip6
  probe.mac_src = src_mac
  probe.mac_dst = packet.mactobin("33:33:00:00:00:01")

  local icmpv6_payload = build_router_advert(src_mac,ula_prefix,prefix_len,valid_lifetime,preferred_lifetime)
  probe:build_icmpv6_header(packet.ND_ROUTER_ADVERT, 0, icmpv6_payload)
  probe:build_ipv6_packet()
  probe:build_ether_frame()

  try(dnet:ethernet_send(probe.frame_buf))

  local expected_mac_dst_prefix = packet.mactobin("33:33:ff:00:00:00")
  local expected_ip6_src = ipOps.ip_to_str("::")
  local expected_ip6_dst_prefix = ipOps.ip_to_str("ff02::1:0:0")

  pcap:set_timeout(1000)
  local pcap_timeout_count = 0
  local nse_timeout = 5
  local start_time = nmap:clock()
  local cur_time = nmap:clock()

  repeat
    local status, length, layer2, layer3 = pcap:pcap_receive()
    cur_time = nmap:clock()
    if not status then
      pcap_timeout_count = pcap_timeout_count + 1
    else
      local l2reply = packet.Frame:new(layer2)
      if string.sub(l2reply.mac_dst, 1, 3) == string.sub(expected_mac_dst_prefix, 1, 3) then
        local reply = packet.Packet:new(layer3)
        if reply and reply.ip_bin_src == expected_ip6_src and
          string.sub(expected_ip6_dst_prefix,1,12) == string.sub(reply.ip_bin_dst,1,12) then
          local ula_target_addr_str = ipOps.str_to_ip(reply.ns_target)
          local identifier = get_identifier(reply.ns_target)
          --Filter out the reduplicative identifiers.
          --A host will send several NS packets with the same interface
          --identifier if it receives several RA packets with different prefix
          --during the discovery phase.
          local actual_addr_str = ipOps.str_to_ip(actual_prefix .. identifier)
          if not results[actual_addr_str] then
            if target.ALLOW_NEW_TARGETS then
              target.add(actual_addr_str)
            end
            results[#results + 1] = { address = actual_addr_str, mac = stdnse.format_mac(l2reply.mac_src), iface = if_nfo.device }
            results[actual_addr_str] = true
          end
        end
      end
    end
  until pcap_timeout_count >= 2 or cur_time - start_time >= nse_timeout

  dnet:ethernet_close()
  pcap:pcap_close()

  condvar("signal")
end

local function format_output(results)
  local output = tab.new()

  for _, record in ipairs(results) do
    tab.addrow(output, "IP: " .. record.address, "MAC: " .. record.mac, "IFACE: " .. record.iface)
  end
  if #results > 0 then
    output = { tab.dump(output) }
    if not target.ALLOW_NEW_TARGETS then
      output[#output + 1] = "Use --script-args=newtargets to add the results as targets"
    end
    return stdnse.format_output(true, output)
  end
end

action = function()
  local threads = {}
  local results = {}
  local condvar = nmap.condvar(results)

  for _, if_nfo in ipairs(stdnse.get_script_interfaces(filter_interfaces)) do
    -- create a thread for each interface
    if ipOps.ip_in_range(if_nfo.address, "fe80::/10") then
      local co = stdnse.new_thread(single_interface_broadcast, if_nfo, results)
      threads[co] = true
    end
  end

  repeat
    for thread in pairs(threads) do
      if coroutine.status(thread) == "dead" then threads[thread] = nil end
    end
    if ( next(threads) ) then
      condvar "wait"
    end
  until next(threads) == nil

  return format_output(results)
end
