一次扫描的生命周期:OXO 开源漏洞扫描器的工作原理
本文介绍 OXO 在底层是如何工作的。
OXO 开源已有一段时间,本文旨在详细介绍它在底层是如何工作的。我们将了解一次扫描的生命周期,以及 Agent 是什么、它们在运行扫描时扮演什么角色。

创建扫描
在本文中,我们将使用 Nmap、Tsunami、OpenVas 和 Nuclei 这几个 Agent 对 IP 8.8.8.8 运行一次扫描,并观察它从开始直到完成的整个生命周期。如果您是第一次创建扫描,请参阅这篇文章:https://oxo.ostorlab.co/tutorials。要运行此扫描,您需要执行以下命令:
oxo scan run --install --agent agent/ostorlab/nmap --agent agent/ostorlab/tsunami --agent agent/ostorlab/nuclei --agent agent/ostorlab/openvas ip 8.8.8.8
如需了解上述扫描命令的完整解析,请查看这篇教程。
首先,在深入了解扫描从开始到结束究竟如何运行之前,我们先来看一些重要的概念。
Agent 与 Agent 组
OXO 依靠 Agent 来执行实际的扫描,这些 Agent 是 OXO 检测能力的核心。每个 Agent 都有一个 Agent 定义 YAML 文件。该文件指向 Docker 文件,而 Docker 文件可以位于代码仓库中的任何位置。该文件定义了一组特定于该 Agent 的属性,例如 Docker 文件的路径、它接受的参数,以及它的输入和输出选择器。选择器表示 Agent 所期望或关心的消息类型。Agent 监听通过其输入选择器传入的消息,并可通过其输出选择器发回消息。
Agent 可以执行各种任务,从安全扫描、文件分析到子域名枚举,并且能够与其他 Agent 通信。
Agent 定义文件的模式在 agent_schema.json 文件中规定,其中包含对定义文件的完整描述。每个定义文件的必需属性为 name、kind(Agent 或 AgentGroup)、in_selectors 和 out_selectors。
使用 Docker 打包应用的一个难题是生成镜像的大小。过去,您需要两个 Docker 文件,一个用于开发,另一个用于生产。为了解决这个问题,OXO 建议采用构建器模式来优化 Agent 镜像。这可以确保生成的镜像更小,并且无需维护两个 Docker 文件。
Agent 的主文件(此处为 nmap_agent.py)是其入口点。
Nmap Agent 的 Dockerfile 示例
FROM python:3.8-alpine as base
FROM base as builder
RUN apk add build-base
RUN mkdir /install
WORKDIR /install
COPY requirement.txt /requirement.txt
RUN pip install --prefix=/install -r /requirement.txt
FROM base
RUN apk update && apk add nmap && apk add nmap-scripts
COPY --from=builder /install /usr/local
RUN mkdir -p /app/agent
ENV PYTHONPATH=/app
COPY agent /app/agent
COPY ostorlab.yaml /app/agent/ostorlab.yaml
WORKDIR /app
CMD ["python3", "/app/agent/nmap_agent.py"]
Agent 的结构
所有 Agent 都应继承 Agent 类,以使用各种功能,例如自动消息序列化、消息接收与发送、选择器注册、Agent 健康检查等。值得注意的是,Agent 既可以是消息处理器,也可以是独立运行的。独立 Agent 可以是长时间运行的,也可以是只运行一次的。
处理消息的 Agent 必须实现 process 方法,并声明一组监听选择器。这些选择器在 YAML Agent 定义文件中定义。
独立 Agent 没有任何需要监听的选择器,必须在 start 方法中实现其逻辑。其用例包括输入注入器,例如资产注入器或配置注入器;也包括长时间运行的进程,例如代理。
Agent 可以选择定义一个自定义健康检查方法 is_healthy,用于告知运行时该 Agent 已可正常运行,并确保在 Agent 完成设置之前不会注入扫描资产。
需要持久化数据的 Agent 必须继承 AgentPersistMixin,它实现了持久化数据的逻辑。该 mixin 可以为一组 Agent 副本或为需要可靠存储的单个 Agent 提供分布式存储。
Agent 还可以选择继承 AgentReportVulnMixin,它实现了从知识库获取条目并发出漏洞消息的逻辑。
如需深入了解 Agent 的实现方式,请参阅这篇教程。
Nmap Agent 的 Agent 定义文件示例。此文件中定义的属性特定于每个 Agent。
kind: Agent
name: nmap
version: 0.4.0
image: images/logo.png
description: |
Agent responsible for network discovery and security auditing using Nmap.
license: Apache-2.0
source: https://github.com/Ostorlab/agent_nmap
in_selectors:
- v3.asset.ip.v4
- v3.asset.ip.v6
- v3.asset.domain_name
- v3.asset.link
out_selectors:
- v3.asset.ip.v4.port.service
- v3.asset.ip.v6.port.service
- v3.asset.domain_name.service-
- v3.report.vulnerability
docker_file_path : Dockerfile
docker_build_root : .
args:
- name: "ports"
type: "string"
description: "List of ports to scan."
value: "0-65535"
- name: "timing_template"
type: "string"
description: "Template of timing settings (T0, T1, ... T5)."
value: "T4"
- kind:单个 Agent 时为
Agent,如需支持多个 Agent 则为AgentGroup。 - name:Agent 的名称。应为小写,且只包含字母和数字字符。
- version:应遵循语义化版本规范。
- image:Agent 图片或徽标的路径。
- description:Agent 的描述。
- source:Agent 的源代码仓库。
- in_selectors:Agent 用于监听传入消息的选择器。
- out_selectors:Agent 用于发回消息的选择器。
- args:我们希望传递给 Agent 的参数,此处为
ports,即要扫描的端口列表。 - docker_file_path:用于组装 Agent Docker 镜像的 Dockerfile 的路径。
- docker_build_root:用于自动化发布构建的 Docker 构建目录。
支持的 Agent mixin
-
AgentHealthcheckMixin:Agent 健康检查 mixin 用于确保 Agent 正常运行。它运行一个 Web 服务(默认为 0.0.0.0:5000),该服务必须返回 200 状态码,并以 OK 作为响应。该 mixin 支持添加多个健康检查回调,这些回调必须全部返回 True。状态通过端点http://0.0.0.0:5000/status进行检查:如果一切正常则返回 OK,若其中某项检查失败则返回 NOK。由于检查可能以其他方式失败,例如某个检查回调抛出异常,该 Web 服务有意不捕获这些异常,以便更容易发现和调试问题。健康检查应检查 OK 是否存在,而不是检查 NOK 是否不存在。
典型用法:
status_agent = agent_healthcheck_mixin.AgentHealthcheckMixin()
status_agent.add_healthcheck(self._is_healthy)
status_agent.start()
-
AgentReportVulnMixin:该 mixin 实现了从知识库获取条目并发出漏洞消息的逻辑。 -
AgentPersistMixin:该 mixin 实现了持久化数据的逻辑。它可以为一组 Agent 副本或为需要可靠存储的单个 Agent 提供分布式存储。一个典型的用例是:在多个其他 Agent 产生重复或相似消息的情况下,确保某个消息值只被处理一次。
典型用法:
status_agent = agent_persist_mixin.AgentPersistMixin(agent_settings)
status_agent.set_add('crawler_agent_asset_dna', dna)
is_new = not status_agent.set_is_member()
消息
所有 Agent 都通过消息进行通信。消息可以包含 IP 地址、文件 blob 或子域名列表等信息。消息会根据选择器自动序列化,这使得消息的传输/发送变得十分简单。其格式遵循以下规则:
-
消息使用 protobuf 格式进行序列化。Protobuf 是一种用于序列化结构化数据的数据交换格式。之所以使用 Protobuf,是因为它是一种紧凑的二进制序列化格式,支持字节类型,无需任何中间(开销高昂的)编码。
-
消息遵循层级结构,子选择器的字段必须包含父消息的所有字段。这同时适用于传入和传出的消息。例如,如果选择器
/foo/bor的消息定义如下:
{
color: str
size: int
}
那么选择器为 /foo/bar/baz 的消息必须包含 color 和 size 字段,例如:
{
color: str
size: int
weight: int
}
特定消息中预期包含的字段、字段类型以及这些字段是否为必需字段,均通过 protobuf 指定。针对 Protocol Buffers 特定用途的模式会将数据类型与字段名称关联起来,并使用整数来标识每个字段。
Nmap 的 v3.asset.ip.v4.port 输出选择器的 protobuf 文件示例
syntax = "proto2";
package v3.asset.ip.v4.port;
message Message {
optional string host = 1;
optional string mask = 2;
optional int32 version = 3 [default = 4];
optional uint32 port = 5;
optional string protocol = 6;
optional string state = 7;
}
所有 proto 都定义在 ostorlab > agent > message > proto > v3 中。v3 中的每个文件夹都以其对应的选择器命名。例如,asset 文件夹包含所有以 v3.asset* 开头的选择器的 proto。

Message 数据类的结构
消息数据类负责根据源选择器或目标选择器进行数据的序列化和反序列化。该数据类有两个方法:from_data 和 from_raw。这些方法提供了一种便捷的方式,让您无需直接处理 protobuf 消息以及不太友好的 protobuf API。
-
from_data:根据结构化数据和目标选择器(用于定义消息格式的目标选择器)生成消息,并返回同时包含原始定义和数据定义的消息。传递给此方法的选择器必须指定所使用的版本。 -
from_raw:根据原始数据(将被反序列化为 Python 数据结构的原始数据)和源选择器生成消息,并返回同时包含原始定义和数据定义的消息。选择器必须指定所使用的版本。
可扩展性
Agent 支持动态运行多个副本。Agent SDK 提供了实现多实例功能的工具,例如分布式锁、分布式存储和消息负载均衡。
可以通过查找 Docker 服务名称并运行以下命令来动态扩展 Agent:
docker service scale <service_name>=<replicas_count>
也可以在运行扫描时,直接在 Agent 组定义 YAML 文件中设置初始副本数。
本地运行时
OXO 支持多种运行时,并自带一个本地运行时。本地运行时凭借易用和轻量的特性,在本地的 Docker Swarm 上运行。对多种运行时的支持使得添加其他运行时成为可能,例如在 Kubernetes 之上运行扫描 Agent。运行时会启动一个原生 RabbitMQ 服务和一个原生 Redis 服务,启动 AgentRunDefinition 中列出的所有 Agent,确保它们处于健康状态,然后注入目标资产。
1. 创建数据库
在启动 Docker Swarm 服务之前,运行时会执行一些检查,以确认 Docker 已安装、Docker 正常运行,并且用户有权限运行 Docker。
如果所有检查都通过,运行时就会实例化一个 SQLite 数据库,创建所需的表,然后在数据库中创建扫描实例。该数据库将用于在本地存储扫描结果。数据库位于 .ostorlab 私有目录中,以 db.sqlite 文件的形式存储。
2. 创建服务
运行时会创建一个 Docker Swarm 网络,供所有服务和 Agent 在其中通信,然后启动一个充当消息总线的本地 RabbitMQ 服务,以及一个用于临时存储扫描数据和实现分布式锁的本地 Redis 服务。在进行下一步之前,运行时会检查 RabbitMQ 和 Redis 服务是否正在运行且处于健康状态。如果其中任一服务不健康,就会抛出 UnhealthyService 异常。
3. 启动前置 Agent
前置 Agent 是必须先于其他 Agent 存在的 Agent。这适用于所有在 Agent 启动时就可能开始发送数据的持久化 Agent。
假设服务都处于健康状态,运行时会分 3 个阶段启动 Agent。这是因为有些 Agent 必须在所有 Agent 启动之前运行,例如跟踪扫描进度的 tracker;而有些 Agent 必须在所有 Agent 都已启动并正常工作之后运行,例如注入资产的 Agent。下一步是验证前置 Agent 是否健康,如果不健康,则抛出 AgentNotHealthy 异常。
4. 启动 Agent
在确认前置 Agent 健康之后,就该启动 Agent 组定义中列出的 Agent 了。Agent 组定义是一组 Agent 及其配置。对于 Agent 组定义中的每个 Agent,都会依次执行一系列步骤。首先,我们可以选择传入额外的 Docker 配置和挂载,然后检查该 Agent 是否已安装(即镜像是否存在于 Docker 容器中),接着实例化一个运行时 Agent,由它负责整合 Agent 设置和 Agent 默认定义,然后创建 Agent 服务。如果在创建扫描时传入了 --follow 标志,运行时会开始流式输出 Agent 服务的日志。如果有任何 Agent 不健康,我们就会抛出异常。
5. 注入资产
为了注入资产,我们首先为 agent/ostorlab/inject_asset Agent 创建 Agent 设置,然后将其连同 Docker 客户端、MQ 服务和 Redis 服务一起传给 AgentRuntime 类。AgentRuntime 类负责整合 Agent 设置和 Agent 默认设置。该类返回一个运行时 Agent,我们在其上调用 create_agent_service 方法,以使用适当的配置和策略创建 Docker Agent 服务。随后,扫描进度被设置为 IN_PROGRESS。
6. 启动后置 Agent
好了!在确认扫描已成功创建之前,只剩最后一步了。我们需要启动后置 Agent,即必须在其他 Agent 之后存在的 Agent。这适用于需要监控其他 Agent 并处理扫描生命周期的 tracker Agent。然后,我们检查后置 Agent 是否健康,如果健康,扫描就创建成功了。
在上述步骤中抛出的任何异常都会导致扫描停止。
7. 处理消息
当调用 run 方法开始运行扫描时,会启动健康检查,并开始监听新消息。每个 Agent 都会向队列发布消息并从队列中消费消息,且只处理该 Agent 所指定选择器的消息。同样,当 Agent 处理完消息后,它会向所有在指定选择器上监听的 Agent 发送消息。我们来看看 Agent 是如何向队列发布消息以及从队列消费消息的。
- 如果您尚未创建扫描,请务必使用以下命令创建
oxo scan run --install --agent agent/ostorlab/nmap --agent agent/ostorlab/tsunami --agent agent/ostorlab/nuclei --agent agent/ostorlab/openvas ip 8.8.8.8
- 使用以下命令列出所有正在运行的容器
docker ps
- 使用以下命令连接到 RabbitMQ 容器
docker exec -it <container_id> bash
container_id 是我们的 RabbitMQ 容器的 ID。
- 要列出队列,我们使用以下命令:
rabbitmqadmin list queues name node messages messages_ready messages_unacknowledged message_stats.publish_details.rate message_stats.deliver_get_details.rate message_stats.publish
上述命令的输出示例

漏洞上报
如果 Agent 需要上报漏洞,只需将 Ostorlab 的知识库条目传给 report_vulnerability 方法即可轻松完成。该方法会从知识库中获取该条目的详细信息,并发出一条漏洞消息。
内置知识库并不是必需的,该 API 也支持持久化任意的漏洞条目。
Nmap 上报漏洞的示例:
scan_result_technical_detail = process_scans.get_technical_details(scan_results)
if normal_results is not None:
technical_detail = f'{scan_result_technical_detail}\n```xml\n{normal_results}\n```'
self.report_vulnerability(entry=kb.KB.NETWORK_PORT_SCAN,
technical_detail=technical_detail,
risk_rating=vuln_mixin.RiskRating.INFO)
可以使用以下命令查看上报的漏洞:
oxo vulnz describe -s [scan-id]
本文深入介绍了 OXO 在幕后是如何工作的。由于该平台在持续演进,这里讨论的概念也可能随之发展,以应对更多用例和未来的需求。