##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
#
# this module sends IEC104 commands
#
include Msf::Exploit::Remote::Tcp
def initialize(info = {})
super(update_info(info,
'Name' => 'IEC104 Client Utility',
'Description' => %q(
This module allows sending 104 commands.
),
'Author' =>
[
'Michael John <mjohn.info[at]gmail.com>'
],
'License' => MSF_LICENSE,
'Actions' =>
[
['SEND_COMMAND', 'Description' => 'Send command to device']
],
'DefaultAction' => 'SEND_COMMAND'))
register_options(
[
Opt::RPORT(2404),
OptInt.new('ORIGINATOR_ADDRESS', [true, "Originator Address", 0]),
OptInt.new('ASDU_ADDRESS', [true, "Common Address of ASDU", 1]),
OptInt.new('COMMAND_ADDRESS', [true, "Command Address / IOA Address", 0]),
OptInt.new('COMMAND_TYPE', [true, "Command Type", 100]),
OptInt.new('COMMAND_VALUE', [true, "Command Value", 20])
]
)
end
# sends the frame data over tcp connection and returns received string
# using sock.get is causing quite some delay, but script needs to process responses from 104 server
def send_frame(data)
begin
sock.put(data)
sock.get(-1, sock.def_read_timeout)
rescue StandardError => e
print_error("Error:" + e.message)
end
end
# ACPI formats:
# TESTFR_CON = '\x83\x00\x00\x00'
# TESTFR_ACT = '\x43\x00\x00\x00'
# STOPDT_CON = '\x23\x00\x00\x00'
# STOPDT_ACT = '\x13\x00\x00\x00'
# STARTDT_CON = '\x0b\x00\x00\x00'
# STARTDT_ACT = '\x07\x00\x00\x00'
# creates and STARTDT Activation frame -> answer should be a STARTDT confirmation
def startcon
apci_data = "\x68"
apci_data << "\x04"
apci_data << "\x07"
apci_data << "\x00"
apci_data << "\x00"
apci_data << "\x00"
apci_data
end
# creates and STOPDT Activation frame -> answer should be a STOPDT confirmation
def stopcon
apci_data = "\x68"
apci_data << "\x04"
apci_data << "\x13"
apci_data << "\x00"
apci_data << "\x00"
apci_data << "\x00"
apci_data
end
# creates the acpi header of a 104 message
def make_apci(asdu_data)
apci_data = "\x68"
apci_data << [asdu_data.size + 4].pack("c") # size byte
apci_data << String([$tx].pack('v'))
apci_data << String([$rx].pack('v'))
$rx = $rx + 2
$tx = $tx + 2
apci_data << asdu_data
apci_data
end
# parses the header of a 104 message
def parse_headers(response_data)
if !response_data[0].eql?("\x04") && !response_data[1].eql?("\x01")
$rx = + (response_data[2].unpack('H*').first + response_data[1].unpack('H*').first).to_i(16)
print_good(" TX: " + response_data[4].unpack('H*').first + response_data[3].unpack('H*').first + \
" RX: " + response_data[2].unpack('H*').first + response_data[1].unpack('H*').first)
end
if response_data[7].eql?("\x07")
print_good(" CauseTx: " + response_data[7].unpack('H*').first + " (Activation Confirmation)")
elsif response_data[7].eql?("\x0a")
print_good(" CauseTx: " + response_data[7].unpack('H*').first + " (Termination Activation)")
elsif response_data[7].eql?("\x14")
print_good(" CauseTx: " + response_data[7].unpack('H*').first + " (Inrogen)")
elsif response_data[7].eql?("\x0b")
print_good(" CauseTx: " + response_data[7].unpack('H*').first + " (Feedback by distant command / Retrem)")
elsif response_data[7].eql?("\x03")
print_good(" CauseTx: " + response_data[7].unpack('H*').first + " (Spontaneous)")
elsif response_data[7].eql?("\x04")
print_good(" CauseTx: " + response_data[7].unpack('H*').first + " (Initialized)")
elsif response_data[7].eql?("\x05")
print_good(" CauseTx: " + response_data[7].unpack('H*').first + " (Interrogation)")
elsif response_data[7].eql?("\x06")
print_good(" CauseTx: " + response_data[7].unpack('H*').first + " (Activiation)")
# 104 error messages
elsif response_data[7].eql?("\x2c")
print_error(" CauseTx: " + response_data[7].unpack('H*').first + " (Type Identification Unknown)")
elsif response_data[7].eql?("\x2d")
print_error(" CauseTx: " + response_data[7].unpack('H*').first + " (Cause Unknown)")
elsif response_data[7].eql?("\x2e")
print_error(" CauseTx: " + response_data[7].unpack('H*').first + " (ASDU Address Unknown)")
elsif response_data[7].eql?("\x2f")
print_error(" CauseTx: " + response_data[7].unpack('H*').first + " (IOA Address Unknown)")
elsif response_data[7].eql?("\x6e")
print_error(" CauseTx: " + response_data[7].unpack('H*').first + " (Unknown Comm Address ASDU)")
end
end
##############################################################################################################
# following functions parse different 104 ASDU messages and prints it content, not all messages of the standard are currently implemented
##############################################################################################################
def parse_m_sp_na_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000 # this bit determines the object addressing structure
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3] # extract ioa value
response_data = response_data[3..-1] # cut ioa from message
i = 0
while response_data.length >= 1
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" SIQ: 0x" + response_data[0].unpack('H*').first)
response_data = response_data[1..-1]
i += 1
end
else
while response_data.length >= 4
ioa = response_data[0..3] # extract ioa
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" SIQ: 0x" + response_data[3].unpack('H*').first)
response_data = response_data[4..-1]
end
end
end
def parse_m_me_nb_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 3
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" Value: 0x" + response_data[0..1].unpack('H*').first + " QDS: 0x" + response_data[2].unpack('H*').first)
response_data = response_data[3..-1]
i += 1
end
else
while response_data.length >= 6
ioa = response_data[0..5]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" Value: 0x" + response_data[3..4].unpack('H*').first + " QDS: 0x" + + response_data[5].unpack('H*').first)
response_data = response_data[6..-1]
end
end
end
def parse_c_sc_na_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 1
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" DIQ: 0x" + response_data[0].unpack('H*').first)
response_data = response_data[1..-1]
i += 1
end
else
while response_data.length >= 4
ioa = response_data[0..3]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" DIQ: 0x" + response_data[3].unpack('H*').first)
response_data = response_data[4..-1]
end
end
end
def parse_m_dp_na_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 1
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" SIQ: 0x" + response_data[0].unpack('H*').first)
response_data = response_data[1..-1]
i += 1
end
else
while response_data.length >= 4
ioa = response_data[0..3]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" SIQ: 0x" + response_data[3].unpack('H*').first)
response_data = response_data[4..-1]
end
end
end
def parse_m_st_na_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 2
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" VTI: 0x" + response_data[0].unpack('H*').first + " QDS: 0x" + response_data[1].unpack('H*').first)
response_data = response_data[2..-1]
i += 1
end
else
while response_data.length >= 5
ioa = response_data[0..4]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" VTI: 0x" + response_data[3].unpack('H*').first + " QDS: 0x" + response_data[4].unpack('H*').first)
response_data = response_data[5..-1]
end
end
end
def parse_m_dp_tb_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 8
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" DIQ: 0x" + response_data[0].unpack('H*').first)
print_cp56time2a(response_data[1..7])
response_data = response_data[8..-1]
i += 1
end
else
while response_data.length >= 11
ioa = response_data[0..10]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" DIQ: 0x" + response_data[3].unpack('H*').first)
print_cp56time2a(response_data[4..10])
response_data = response_data[11..-1]
end
end
end
def parse_m_sp_tb_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 8
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" SIQ: 0x" + response_data[0].unpack('H*').first)
print_cp56time2a(response_data[1..7])
response_data = response_data[8..-1]
i += 1
end
else
while response_data.length >= 11
ioa = response_data[0..10]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" SIQ: 0x" + response_data[3].unpack('H*').first)
print_cp56time2a(response_data[4..10])
response_data = response_data[11..-1]
end
end
end
def parse_c_dc_na_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 1
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" DCO: 0x" + response_data[0].unpack('H*').first)
response_data = response_data[1..-1]
i += 1
end
else
while response_data.length >= 4
ioa = response_data[0..3]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" DCO: 0x" + response_data[3].unpack('H*').first)
response_data = response_data[4..-1]
end
end
end
def parse_m_me_na_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 3
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" Value: 0x" + response_data[0..1].unpack('H*').first + " QDS: 0x" + response_data[2].unpack('H*').first)
response_data = response_data[3..-1]
i += 1
end
else
while response_data.length >= 6
ioa = response_data[0..3]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" Value: 0x" + ioa[3..4].unpack('H*').first + " QDS: 0x" + response_data[5].unpack('H*').first)
response_data = response_data[6..-1]
end
end
end
def parse_m_me_nc_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 5
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" Value: 0x" + response_data[0..3].unpack('H*').first + " QDS: 0x" + response_data[4].unpack('H*').first)
response_data = response_data[5..-1]
i += 1
end
else
while response_data.length >= 8
ioa = response_data[0..3]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" Value: 0x" + response_data[3..6].unpack('H*').first + " QDS: 0x" + response_data[7].unpack('H*').first)
response_data = response_data[8..-1]
end
end
end
def parse_m_it_na_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
response_data = response_data[11..-1]
ioa = response_data[0..3]
i = 0
while response_data.length >= 5
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" Value: 0x" + response_data[0..3].unpack('H*').first + " QDS: 0x" + response_data[4].unpack('H*').first)
response_data = response_data[5..-1]
i += 1
end
else
while response_data.length >= 8
ioa = response_data[0..3]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" Value: 0x" + response_data[3..6].unpack('H*').first + " QDS: 0x" + response_data[7].unpack('H*').first)
response_data = response_data[8..-1]
end
end
end
def parse_m_bo_na_1(response_data)
sq_bit = Integer(response_data[6].unpack('C').first) & 0b10000000
response_data = response_data[11..-1] # cut out acpi data
if sq_bit.eql?(0b10000000)
ioa = response_data[0..3]
response_data = response_data[3..-1]
i = 0
while response_data.length >= 5
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16) + i) + \
" Value: 0x" + response_data[0..3].unpack('H*').first + " QDS: 0x" + response_data[4].unpack('H*').first)
response_data = response_data[5..-1]
i += 1
end
else
while response_data.length >= 8
ioa = response_data[0..3]
print_good(" IOA: " + String((ioa[2].unpack('H*').first + ioa[1].unpack('H*').first + ioa[0].unpack('H*').first).to_i(16)) + \
" Value: 0x" + response_data[3..6].unpack('H*').first + " QDS: 0x" + response_data[7].unpack('H*').first)
response_data = response_data[8..-1]
end
end
end
# function to parses time format used in IEC 104
# function ported to ruby from: https://github.com/Ebolon/iec104
def print_cp56time2a(buf)
us = ((Integer(buf[1].unpack('c').first) & 0xFF) << 8) | (Integer(buf[0].unpack('c').first) & 0xFF)
second = Integer(us) / 1000
us = us % 1000
minute = Integer(buf[2].unpack('c').first) & 0x3F
hour = Integer(buf[3].unpack('c').first) & 0x1F
day = Integer(buf[4].unpack('c').first) & 0x1F
month = (Integer(buf[5].unpack('c').first) & 0x0F) - 1
year = (Integer(buf[6].unpack('c').first) & 0x7F) + 2000
print_good(" Timestamp: " + String(year) + "-" + String(format("%02d", month)) + "-" + String(format("%02d", day)) + " " + \
String(format("%02d", hour)) + ":" + String(format("%02d", minute)) + ":" + String(format("%02d", second)) + "." + String(us))
end
##############################################################################################################
# END of individual parse functions section
##############################################################################################################
# parses the 104 response string of a message
def parse_response(response)
response_elements = response.split("\x68")
response_elements.shift
response_elements.each do |response_element|
if response_element[5].eql?("\x64")
print_good(" Parsing response: Interrogation command (C_IC_NA_1)")
parse_headers(response_element)
elsif response_element[5].eql?("\x01")
print_good(" Parsing response: Single point information (M_SP_NA_1)")
parse_headers(response_element)
parse_m_sp_na_1(response_element)
elsif response_element[5].eql?("\x0b")
print_good(" Parsing response: Measured value, scaled value (M_ME_NB_1)")
parse_headers(response_element)
parse_m_me_nb_1(response_element)
elsif response_element[5].eql?("\x2d")
print_good(" Parsing response: Single command (C_SC_NA_1)")
parse_headers(response_element)
parse_c_sc_na_1(response_element)
elsif response_element[5].eql?("\x03")
print_good(" Parsing response: Double point information (M_DP_NA_1)")
parse_headers(response_element)
parse_m_dp_na_1(response_element)
elsif response_element[5].eql?("\x05")
print_good(" Parsing response: Step position information (M_ST_NA_1)")
parse_headers(response_element)
parse_m_st_na_1(response_element)
elsif response_element[5].eql?("\x1f")
print_good(" Parsing response: Double point information with time (M_DP_TB_1)")
parse_headers(response_element)
parse_m_dp_tb_1(response_element)
elsif response_element[5].eql?("\x2e")
print_good(" Parsing response: Double command (C_DC_NA_1)")
parse_headers(response_element)
parse_c_dc_na_1(response_element)
elsif response_element[5].eql?("\x1e")
print_good(" Parsing response: Single point information with time (M_SP_TB_1)")
parse_headers(response_element)
parse_m_sp_tb_1(response_element)
elsif response_element[5].eql?("\x09")
print_good(" Parsing response: Measured value, normalized value (M_ME_NA_1)")
parse_headers(response_element)
parse_m_me_na_1(response_element)
elsif response_element[5].eql?("\x0d")
print_good(" Parsing response: Measured value, short floating point value (M_ME_NC_1)")
parse_headers(response_element)
parse_m_me_nc_1(response_element)
elsif response_element[5].eql?("\x0f")
print_good(" Parsing response: Integrated total without time tag (M_IT_NA_1)")
parse_headers(response_element)
parse_m_it_na_1(response_element)
elsif response_element[5].eql?("\x07")
print_good(" Parsing response: Bitstring of 32 bits without time tag. (M_BO_NA_1)")
parse_headers(response_element)
parse_m_bo_na_1(response_element)
elsif response_element[5].eql?("\x46")
print_good("Received end of initialisation confirmation")
parse_headers(response_element)
elsif response_element[0].eql?("\x04") && response_element[1].eql?("\x01") && response_element[2].eql?("\x00")
print_good("Received S-Frame")
parse_headers(response_element)
elsif response_element[0].eql?("\x04") && response_element[1].eql?("\x0b") && response_element[2].eql?("\x00") && response_element[3].eql?("\x00")
print_good("Received STARTDT_ACT")
elsif response_element[0].eql?("\x04") && response_element[1].eql?("\x23") && response_element[2].eql?("\x00") && response_element[3].eql?("\x00")
print_good("Received STOPDT_ACT")
elsif response_element[0].eql?("\x04") && response_element[1].eql?("\x43") && response_element[2].eql?("\x00") && response_element[3].eql?("\x00")
print_good("Received TESTFR_ACT")
else
print_status("Received unknown message")
parse_headers(response_element)
print_status(response_element.unpack('H*').first)
end
# Uncomment for print received data
# print_good("DEBUG: " + response_element.unpack('H*').first)
end
end
# sends 104 command with configure datastore options
# default values are for a general interrogation command
# for example a switching command would be:
# COMMAND_TYPE => 46 // double command without time
# COMMAND_ADDRESS => 100 // any IOA address that should be switched
# COMMAND_VALUE => 6 // switching off with short pulse
# use value 5 to switch on with short pulse
#
# Structure of 104 message:
# 1byte command type
# 1byte num ix -> 1 (one item send)
# 1byte cause of transmission -> 6 (activation)
# 1byte originator address
# 2byte common adsu address
# 3byte command address
# 1byte command value
def func_send_command
print_status("Sending 104 command")
asdu = [datastore['COMMAND_TYPE']].pack("c") # type of command
asdu << "\x01" # num ix -> only one item is send
asdu << "\x06" # cause of transmission = activation, 6
asdu << [datastore['ORIGINATOR_ADDRESS']].pack("c") # sets originator address of client
asdu << String([Integer(datastore['ASDU_ADDRESS'])].pack('v')) # sets the common address of ADSU
asdu << String([Integer(datastore['COMMAND_ADDRESS'])].pack('V'))[0..2] # sets the IOA address, todo: check command address fits in the 3byte address field
asdu << [datastore['COMMAND_VALUE']].pack("c") # sets the value of the command
# Uncomment for debugging
# print_status("Sending: " + make_apci(asdu).unpack('H*').first)
response = send_frame(make_apci(asdu))
if response.nil?
print_error("No answer")
else
parse_response(response)
end
print_status("operation ended")
end
def run
$rx = 0
$tx = 0
begin
connect
rescue StandardError => e
print_error("Error:" + e.message)
return
end
# send STARTDT_CON to activate connection
response = send_frame(startcon)
if response.nil?
print_error("Could not connect to 104 service")
return
else
parse_response(response)
end
# send the 104 command
case action.name
when "SEND_COMMAND"
func_send_command
else
print_error("Invalid ACTION")
end
# send STOPDT_CON to terminate connection
response = send_frame(stopcon)
if response.nil?
print_error("Terminating Connection")
return
else
print_status("Terminating Connection")
parse_response(response)
end
begin
disconnect
rescue StandardError => e
print_error("Error:" + e.message)
end
end
end
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation