使用Perl进行端口扫描

我最近的信息安全文章主要集中在网络中的活动主机发现上。受到Douglas Berdeaux的《使用Perl进行渗透测试》的启发,我整理了一个强大的扫描器集合,包括ARP、echo、SMB和Netbios。每个都有不同的优势和劣势。无论如何,一旦你发现了一个活动主机并且想要探测漏洞,端口扫描就是下一步的合逻辑步骤。

端口扫描详解

一个IP地址标识了计算机的网络位置,但一旦计算机收到一个UDP数据报或TCP数据包,它就需要决定如何将其内部路由。每个TCP/UDP数据包都包含一个“目的端口”字段,计算机将尝试将数据包/数据报发送到该端口。每台计算机都有65,535个可用的TCP和UDP端口供服务使用。其中许多已经被分配给了常见服务,如22号端口用于SSH,25号端口用于SMTP,80号端口用于HTTP。

端口扫描是探测另一台计算机的端口以了解哪些端口是“打开的”(有服务在其上监听)、“过滤的”(被防火墙阻止访问)和“关闭的”(没有服务在其上监听)。一旦攻击者了解了哪些端口是开放的,他们就可以开始探测这些服务的弱点。例如,如果我针对远程服务器运行端口扫描并发现SMTP的25号端口是开放的,我可以尝试对该端口进行多种攻击。我可以在25号端口上telnet到活动主机的IP地址,并尝试使用‘VRFY’命令在该系统上发现用户名。一旦我有了用户名,我就可以继续进行暴力破解密码尝试——可能在22号端口或者如果该主机上运行了Web应用的话。如果我在25号端口上监听电子邮件服务的缓冲区溢出攻击中取得成功,可能甚至不需要用户名和密码。

使用Perl进行端口扫描

一个基本的端口扫描器需要能够接受活动主机的IP地址,列出一系列端口,向活动主机的每个端口发送数据包,并监听和解析响应。Perl有几个模块可以简化这个过程。我将逐一说明每个要求。

解析命令行参数

我们可以使用Getopt::LongPod::Usage

use Getopt::Long;
use Pod::Usage;

GetOptions (
  'help|?'    => sub { pod2usage(2) },
  'ip=s'      => \my $target_ip,
);

# validate required args are given
die "Missing --ip parameter, try --help\n" unless $target_ip;

__END__

=head1 NAME

port_scanner - a concurrent randomized tcp/udp port scanner written in Perl

=head1 SYNOPSIS

port_scanner [options]

 Options:
  --ip,     -i   ip address to scan e.g. 10.30.1.52
  --help,   -h   display this help text

GetOptions函数解析命令行参数并将它们分配给变量。Getopt::Long可以处理缩写选项名,所以--ip 10.0.1.5-i 10.0.1.5都将IP地址分配给变量$target_ip。如果程序接收到--help-h-?,它将使用pod2usage打印出文档。

发现本地IP和端口

为了发送IP数据包,我们需要目标IP地址和本地IP地址。我们还需要一个本地端口。

use Net::Address::IP::Local;
use IO::Socket::INET;

my $local_ip   = Net::Address::IP::Local->public;

# find a random free port by opening a socket using the protocol
my $local_port = do {
  my $socket = IO::Socket::INET->new(Proto => 'tcp', LocalAddr => $local_ip);
  my $socket_port = $socket->sockport();
  $socket->close;
  $socket_port;
};

要获取本地IP地址,我调用由Net::Address::IP::Local模块提供的public方法。很简单!找到可用的本地端口则更为复杂。理论上任何未命名的端口都应该可用,但可能有其他服务已经在使用它。因此,我使用IO::Socket::INET创建一个新的套接字对象,而没有指定本地端口。在底层,这尝试在端口零上打开套接字,然后操作系统将自动为套接字分配一个可用端口(零是保留的)。这还有一个额外的优点,即每次运行扫描器时都会随机化使用的本地端口。然后我保存套接字打开的端口号,并关闭套接字。

获取要扫描的端口号列表

对于我们的简单扫描器,我将专注于扫描已命名的端口,即由IANA预分配给服务的端口号。幸运的是,NMAP工具背后的开发者已经收集了一个已命名的端口文本文件,我将使用这个文件。

use List::Util 'shuffle';

my %port_directory;
open my $port_file, '<', 'data/nmap-services.txt'
  or die "Error reading data/nmap-services.txt $!\n";

while (<$port_file>)
{
  next if /^#/; # skip comments
  chomp;
  my ($name, $number_protocol, $probability, $comments) = split /\t/;
  my ($port, $proto) = split /\//, $number_protocol;

  $port_directory{$number_protocol} = {
    port        => $port,
    proto       => $proto,
    name        => $name,
    probability => $probability,
    comments    => $comments,
  };free
}

my @ports = shuffle do {
    map { $port_directory{$_}->{port} }
      grep { $port_directory{$_}->{name} !~ /^unknown$/
             && $port_directory{$_}->{proto} eq 'tcp' } keys %port_directory;
};

此代码首先从List::Util导入shuffle函数,我在稍后使用它来随机化端口号列表的顺序。然后我打开一个文件句柄到nmap-services文本文件,通过循环构建%port_directory哈希。最后,我使用grep遍历端口号目录,提取所有未标记为“未知”的tcp端口,使用map从哈希中提取端口号,将端口号随机化到@ports中(在Perl的新版本中,shuffle可能是不必要的,因为哈希键的顺序已经随机化了)。

发送数据包并监听响应

我们需要同时发送数据包并监听响应,因为我们如果先发送数据包然后再监听数据包,我们可能会错过中间的一些响应。为此,我使用fork创建子进程以发送数据包,并使用父进程监听响应。

use Net::Pcap;
use POSIX qw/WNOHANG ceil/;

# apportion the ports to scan between processes
my $procs = 50;
my $batch_size = ceil(@ports / $procs);
my %total_ports = map { $_ => 'filtered' } @ports; # for reporting
my @child_pids;

for (1..$procs)
{
  my @ports_to_scan = splice @ports, 0, $batch_size;
  my $parent = fork;
  die "unable to fork!\n" unless defined ($parent);

  if ($parent)
  {
    push(@child_pids, $parent);
    next;
  }

  # child waits until the parent signals to continue
  my $continue = 0;
  local $SIG{CONT} = sub { $continue = 1};
  until ($continue) {}

  for my $target_port (@ports_to_scan)
  {
    sleep(1);
    send_packet($target_port);
  }
  exit 0; # exit child
}

# setup parent packet capture
my $device_name = pcap_lookupdev(\my $err);
pcap_lookupnet($device_name, \my $net, \my $mask, \$err);
my $pcap = pcap_open_live($device_name, 1024, 0, 1000, \$err);
pcap_compile(
  $pcap,
  \my $filter,
  "(src net $target_ip) && (dst port $local_port)",
  0,
  $mask
);
pcap_setfilter($pcap,$filter);

# signal the child pids to start sending
kill CONT => $_ for @child_pids;

until (waitpid(-1, WNOHANG) == -1) # until all children exit
{
  my $packet_capture = pcap_next_ex($pcap,\my %header,\my $packet);

  if($packet_capture == 1)
  {
    read_packet($packet);
  }
  elsif ($packet_capture == -1)
  {
    warn "libpcap errored while reading a packet\n";
  }
}

这是一段需要处理的很多代码,但我会概述其主要内容。代码创建了50个子进程,并将一组端口分配给每个子进程。我在每个子进程中安装了一个信号处理器来处理CONT信号,并暂停子进程,直到接收到该信号。这是为了防止子进程在父进程尚未准备好捕获的情况下提前发送数据包。一旦创建了所有子进程,父进程使用Lib::Pcap设置了一个数据包捕获对象。捕获对象被分配了一个用于$target_ip和之前发现的$local_port的过滤器。

然后,父进程使用kill向子进程发送信号,子进程开始使用定义在下面的send_packet发送数据包。最后,父进程通过使用waitpid来决定所有子进程何时完成发送数据包并退出。在循环期间,每次父进程收到一个新数据包时,都会调用定义在下面的read_packet

你可能想知道常量WNOHANG的作用是什么。当waitpid函数以-1作为参数调用时,它会尝试回收任何已经终止的子进程。在优秀的《Perl网络编程》(Network Programming with Perl)一书中,Lincoln Stein解释了三种可能导致waitpid挂起或丢失对子进程跟踪的情况:如果子进程因信号而终止或重启,如果两个子进程几乎同时终止,或者如果父进程不小心通过系统调用创建了新的子进程。WNOHANG可以防止这些情况,确保所有子进程都能被父进程正确回收。

现在让我们看看send_packet子例程

use Net::RawIP;

sub send_packet
{
  my ($target_port) = @_;

  Net::RawIP->new({ ip => {
                      saddr => $local_ip,
                      daddr => $target_ip,
                    },
                    tcp => {
                      source => $local_port,
                      dest   => $target_port,
                      syn => 1,
                    },
                  })->send;
}

这段代码使用了很少受到重视的Net::RawIP模块来构造TCP数据包并将它们发送到目标目的地。我们设置SYN标志为1来触发TCP三次握手过程的开始,而我们永远不会完成它。这是一种隐蔽的发现端口的方式——由于我们没有完成握手,我们的请求不会记录在日志中,除非目标已经配置为捕获这些数据。

read_packet子例程稍微复杂一些

use NetPacket::Ethernet;
use NetPacket::IP;
use NetPacket::TCP;

sub read_packet
{
  my ($raw_data) = @_;
  my $ip_data = NetPacket::Ethernet::strip($raw_data);
  my $ip_packet = NetPacket::IP->decode($ip_data);

  # is it TCP
  if ($ip_packet->{proto} == 6)
  {
    my $tcp = NetPacket::TCP->decode(NetPacket::IP::strip($ip_data));
    my $port = $tcp->{src_port};
    my $port_name = exists $port_directory{"$port/tcp"}
      ? $port_directory{"$port/tcp"}->{name}
      : '';

    if ($tcp->{flags} & SYN)
    {
      printf " %5d %-20s %-20s\n", $port, 'open', $port_name;
      $total_ports{$port} = 'open';
    }
    elsif ($tcp->{flags} & RST)
    {
      printf " %5d %-20s %-20s\n", $port, 'closed', $port_name;
      $total_ports{$port} = 'closed';
    }
  }
}

我使用NetPacket发行版来解析传入的数据包。第一个检查if ($ip_packet->{proto} == 6)是用来检查我们正在处理一个TCP数据包(每个协议都有一个编号——参见列表)。然后代码解析TCP数据包,并在我们之前创建的%port_directory中查找端口号。SYNRSTNetPacket::TCP导出的常量,它们与TCP头部的标志值进行AND运算,以识别TCP数据包的类型。如果我们收到了一个SYN数据包,这看起来端口是开放的;一个RST数据包表示端口是关闭的。

总结结果

一旦端口扫描完成,所有关闭的和开放的端口都应该打印出来。但还需要考虑被过滤的端口——根据定义,我们永远不会收到对它们的响应。我已经使用%total_ports哈希来跟踪端口的状况。每个端口最初都是“过滤”状态,当收到响应时,被设置为“开放”或“关闭”。然后我们可以使用这些数据来总结结果

printf "\n %d ports scanned, %d filtered, %d closed, %d open\n",
  scalar(keys %total_ports),
  scalar(grep { $total_ports{$_} eq 'filtered' } keys %total_ports),
  scalar(grep { $total_ports{$_} eq 'closed'   } keys %total_ports),
  scalar(grep { $total_ports{$_} eq 'open'     } keys %total_ports);

END { pcap_close($pcap) if $pcap }

END块在Perl程序的最终阶段执行,并关闭数据包捕获对象。如果在程序执行期间接收到INT或TERM信号,则不会执行,因此我可以添加信号处理程序,以确保在接收到信号时Perl能够有序地关闭

BEGIN { $SIG{INT} = $SIG{TERM} = sub { exit 0 } }

我可以在程序开头附近添加这段代码,但BEGIN块确保它在程序启动阶段早期执行,在主代码执行之前。

整合起来

我已经将代码保存到一个程序中。现在我可以在命令行上运行它

$ sudo $(which perl) port_scanner --ip 10.0.1.5

我需要使用sudo,因为libpcap需要root权限才能运行。程序会发出很多输出,这里是一个片段

...
   264 closed               bgmp                
    48 closed               auditd              
  9100 open                 jetdirect 
  2456 closed               altav-remmgt        
  3914 closed               listcrt-port-2      
    42 closed               nameserver          
  1051 closed               optima-vnet         
  1328 closed               ewall               
  4200 closed               vrml-multi-use      
    65 closed               tacacs-ds           
  8400 closed               cvd                 
  8042 closed               fs-agent            
  1516 closed               vpad                
   702 closed               iris-beep           
  1034 closed               zincite-a           
   598 closed               sco-websrvrmg3      

 2258 ports scanned, 25 filtered, 2229 closed, 4 open

注意顺序是随机的,我们找到了4个开放的端口。如果我使用--help运行程序,它会打印出一些有用的说明

Usage:
    port_scanner [options]

     Options:
      --ip,     -i   ip address to scan e.g. 10.30.1.52
      --help,   -h   display this help text

总结

我们的基本端口扫描器可以改进。首先,我们只扫描已命名的端口——如果能接受一个端口范围进行扫描会更好。支持的协议和TCP标志也可以扩展,以对不同机器产生更好的结果。用户还应该能够控制子进程的数量和数据包频率,以调整扫描以适应目标的敏感性。在第二部分中,我将展示如何将这些更改以及更多内容集成到一个功能齐全的端口扫描器中。


这篇文章最初发布在PerlTricks.com上。

标签

大卫·费尔兰

大卫是一位专业程序员,他经常在推特博客上分享关于代码和编程艺术的见解。

浏览他们的文章

反馈

这篇文章有什么问题吗?请通过在GitHub上打开一个issue或pull request来帮助我们。