一个容器化的网络诱饵(蜜罐),它通告SSH、RDP和SMB,通过eBPF观察每个入站连接,并将每个会话反向代理到隔离的诱饵容器中。
该设计分离了两个关注点:
这是一个用于检测和研究您拥有或获授权监控的网络上的未授权活动的防御工具。请仅在您拥有该权限的地方部署。
flowchart TB
A["攻击者 / 扫描器"]
subgraph host["诱饵主机"]
direction TB
NIC["代理 eth0<br/>发布端口: 22, 3389, 445"]
subgraph brk["代理容器"]
direction TB
E["eBPF TC 分类器<br/>记录每个 SYN<br/>看到真实源 IP"]
P["反向代理<br/>连接到后端"]
L["结构化 JSON 日志"]
end
subgraph dec["decoynet (内部网络,无主机路由)"]
direction LR
S["ssh-诱饵<br/>OpenCanary ssh<br/>端口 2222"]
D["rdp-诱饵<br/>OpenCanary rdp<br/>端口 3389"]
M["smb-诱饵<br/>Impacket SMB 服务器<br/>端口 445"]
end
end
A --> NIC
NIC --> E
NIC --> P
E --> L
P --> S
P --> D
P --> M
总共四个容器:
| 容器 | 角色 | 网络 |
|---|---|---|
broker | 公共前门:eBPF 观察 + 反向代理 | edge + decoynet |
ssh-decoy | OpenCanary ssh 模块(真实握手,捕获凭据) | 仅 decoynet |
rdp-decoy | OpenCanary rdp 模块(NLA 模拟,捕获用户名) | 仅 decoynet |
smb-decoy | Impacket SimpleSMBServer(真实 SMB2/3,捕获认证信息) | 仅 decoynet |
诱饵位于一个内部 Docker 网络(decoynet)上,该网络没有到主机或外部世界的路由。只有代理可以访问它们。攻击者在诱饵内所做的任何事情都无法直接到达主机网络。
代理将端口 22、3389 和 445 发布到主机,因此入站数据包到达代理的 eth0。然后每个数据包发生两件事:
broker/bpf/decoy.bpf.c) 解析以太网、IP 和 TCP 头部,对于每个新的连接尝试(SYN 设置,ACK 清除),将一个 conn_event 写入环形缓冲区:源 IP 和端口、目标端口、TCP 标志,以及该端口是否为通告服务。数据包被原样传递 (TC_ACT_OK)。CONNECT 到该服务的诱饵后端的操作,然后双向中继字节。advertised_ports eBPF 映射在启动时从 config.yaml 填充,因此分类器可以标记探测是否命中了提供服务的端口还是未请求的端口。这使得水平端口扫描可见,即使只有三个端口被代理。
如果您想通告“一切开放”并将任意目标端口 funnel 到代理中,可以扩展分类器以重写目标端口,或使用 TPROXY / bpf_sk_assign 重定向。当前版本保持数据包路径不变,仅局限于观察,这是更安全的默认设置。
cyber-decoy/
├── README.md
├── docker-compose.yml # 4 容器栈
├── docker-compose.override.yml # 本地 macOS 开发:无 eBPF 能力,端口 22 重映射
├── Makefile # build / up / down / bpf 辅助命令
├── LICENSE
├── scripts/
│ └── setup.sh # 主机预检检查
├── broker/
│ ├── Dockerfile # 编译 eBPF 对象 + Go 二进制文件
│ ├── config.yaml # 通告的服务(可配置)
│ ├── go.mod
│ ├── main.go # 入口点
│ ├── bpf/
│ │ └── decoy.bpf.c # eBPF TC 分类器
│ └── internal/
│ ├── config/config.go # 配置加载器
│ ├── proxy/proxy.go # TCP 反向代理
│ └── bpf/loader.go # 加载 + 附加 eBPF,流式处理事件
└── decoys/ # 全部三个都运行 OpenCanary
├── ssh/
│ ├── Dockerfile
│ └── opencanary.conf # ssh 模块,端口 2222
├── rdp/
│ ├── Dockerfile
│ └── opencanary.conf # rdp 模块,端口 3389
└── smb/
├── Dockerfile # 单个 Python 进程,非 root
├── smb_decoy.py # Impacket SimpleSMBServer + JSON 日志记录
└── requirements.txt # impacket(固定版本)
docker-compose.override.yml,需要 v2.24+,因为它依赖于 !reset / !override 标签)。sudo mount -t bpf bpf /sys/fs/bpf。代理镜像会检测其构建架构,并将匹配的 __TARGET_ARCH_* 宏传递给 clang,因此它可以在 x86_64 和 aarch64(Apple Silicon、Graviton)上构建。请注意,gcc-multilib 故意未安装:它是一个仅适用于 x86 的软件包,没有 arm64 候选版本,包含它会在 arm64 上导致 apt 退出代码 100 从而破坏构建。编译 eBPF 对象仅需要 clang 和 libbpf-dev。
macOS 上的 Docker Desktop 在 LinuxKit VM 内运行容器,而不是在您的宿主机内核上运行,因此 TC/TCX eBPF 附加通常无法在那里工作。但这并非致命:eBPF 被设计为尽力而为,因此代理会记录 ebpf disabled: attach failed,而反向代理和所有三个诱饵会正常运行并记录日志。您可以在本地开发和测试整个代理路径,然后在部署到 Linux 主机时获得真正的 eBPF 观察。
docker-compose.override.yml 会被自动加载,让这变得愉快:它删除 eBPF 能力(在 VM 中无用)并将主机端口 22 重映射到 2022,因为 Mac 自己的 sshd 占用了 22。
docker compose up --build # 本地开发,覆盖应用
docker compose -f docker-compose.yml up -d # 真实部署,绕过覆盖
首先运行预检检查:
./scripts/setup.sh
# 1. 构建所有四个镜像(在代理镜像内编译 eBPF 对象)
make build
# 2. 启动栈
make up
# 3. 查看发生了什么
make logs
然后从另一台机器(或本地主机进行冒烟测试)探测它:
ssh -p 22 user@DECOY_HOST # 命中 SSH 诱饵
nc DECOY_HOST 3389 # 命中 RDP 诱饵
nc DECOY_HOST 445 # 命中 SMB 诱饵
nc DECOY_HOST 8080 # 未通告:被 eBPF 观察,无代理
代理会为 eBPF 探测事件和代理会话输出 JSON;每个诱饵会输出 OpenCanary JSON 事件。要查看凭据落地:
docker compose logs -f ssh-decoy | grep 4002
与仅显示横幅的桩程序不同,ssh -p 22 user@DECOY_HOST 现在完成真实的密钥交换并提示输入密码。每次尝试都会被捕获。验证服务指纹在版本检测下是否成立:
nmap -sV -p 22,3389,445 DECOY_HOST
使用以下命令拆除:
make down
服务在 broker/config.yaml 中定义。每个条目可独立启用和重新映射:
services:
- name: ssh
enabled: true
listen_port: 22
backend: ssh-decoy:2222
要添加服务,在此处添加条目,在 docker-compose.yml 中发布端口,并添加一个诱饵容器。要禁用一个,设置 enabled: false(并可选择删除其发布的端口)。
请注意,主机端口 22 通常被主机的真实 SSH 守护进程占用。对于实验室,您可以在 docker-compose.yml 中重新映射已发布的一侧,例如 "2022:22",然后将扫描器指向那里。
所有三个诱饵都运行 OpenCanary(Thinkst),每个容器配置为仅启用一个模块。日志以 JSON 格式输出到 stdout,因此 docker compose logs 和任何 SIEM 发送器都可以无需额外管道工作。
OpenCanary 为每个事件标记一个数字 logtype。您在此处将看到的事件:
捕获的 SSH 凭据如下所示:
{"dst_port": 2222, "logtype": 4002, "node_id": "decoy-ssh",
"src_host": "10.0.0.66", "src_port": 42958,
"logdata": {"USERNAME": "admin", "PASSWORD": "Passw0rd123"}}
这是代理架构的直接后果,也是理解这些日志最重要的一个点。
代理终止了攻击者的 TCP 连接,并打开了一个新的连接到诱饵。因此,从 OpenCanary 的角度来看,客户端是代理。诱饵事件中的每个 src_host 都是代理在 decoynet 上的地址,而不是真正的源地址。
真正的源 IP 仍然被捕获,只是位置不同:
因此,归因需要关联代理日志和诱饵日志,通过时间戳和服务进行连接。代理为每个会话记录 remote(真实的攻击者地址)和 backend,这使得关联成为可能:
docker compose logs broker | grep 'session opened' # 谁
docker compose logs ssh-decoy | grep '"logtype": 4002' # 他们尝试了什么
如果您需要在诱饵内部获取真实 IP,选项包括发送 PROXY 协议(诱饵不解析它,因此这意味着需要修补它们),或者将用户空间代理替换为保留原始源地址的透明重定向(TPROXY 或 eBPF bpf_sk_assign)。两者都在路线图中列出。在此之前,将代理视为“谁”的事实来源,将诱饵视为“什么”的事实来源。
与 SSH 和 RDP 不同,此诱饵不使用 OpenCanary。OpenCanary 的 smb 模块只是一个日志监视器:它 tail 一个文件并解析真实 Samba 服务器发出的 smbd_audit 行,这意味着要在 supervisord 下运行 Samba 加 rsyslog 加 opencanaryd,这是一个五链式链接,其中任何一环都可能静默失效。
smb-decoy 将所有这一切替换为基于 Impacket 的 SimpleSMBServer 的单个 Python 进程,这是一个 SMB1/2/3 的纯 Python 实现。它绑定 445,提供只读诱饵共享,应答 SMB2/3 协商(因此 nmap -sV 看到真实服务),并将连接和 NTLM 认证尝试作为每行一个 JSON 对象记录到 stdout。攻击者认证消息中捕获的用户名、域名和工作站是凭据捕获的回报。
安全说明:Impacket 的 smbserver 存在一个关键的路径遍历漏洞 CVE-2021-31800,该漏洞特别影响蜜罐。该漏洞已在 0.9.23 中修复。requirements.txt 固定了一个当前版本,并且不得降级到该版本以下。该容器还以非 root、只读方式运行,并且除了 NET_BIND_SERVICE 之外的所有能力都被删除。
每个诱饵拥有一个 opencanary.conf(安装到 /etc/opencanaryd/)。有用的旋钮:
decoys/ssh/opencanary.conf 中的 ssh.version。它目前声称 SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1。让它与您假装的操作系统匹配;在声称是 Windows 的盒子上显示 Ubuntu 横幅是一个破绽。decoys/smb/smb_decoy.py 中的 addShare(...) 调用,以及 decoys/smb/Dockerfile 中创建的诱饵文件。共享名称和文件名是诱饵。broker/config.yaml 中的 backend 对齐。要启用另一个 OpenCanary 模块(ftp、telnet、mysql、vnc、redis 等均可使用),设置 <module>.enabled 和 <module>.port,添加一个诱饵容器,并在 broker/config.yaml 中添加一个匹配的服务。
ssh-decoy 在 /var/lib/opencanary 挂载了一个命名卷(ssh.key_path),因此生成的主机密钥在重启后保持不变。没有它,OpenCanary 会在每次启动时生成新密钥,不断变化的指纹是一个明显的破绽。
SMB 诱饵现在是一个单个进程,因此故障排除非常直接。
docker compose logs -f smb-decoy
每一行都是 JSON。启动时应该看到一条 smb_decoy_start 消息,然后随着客户端交互,会看到 smb_connect、smb_auth_attempt 和 smb_tree_connect 事件。从主机使用任何 SMB 客户端测试:
# macOS Finder: 前往 > 连接到服务器
open 'smb://guest@localhost/HR-Payroll'
# 或从 Linux
smbclient -L //localhost -p 445 -N
常见问题:
smb_decoy_start 行且容器退出:检查 requirements.txt 是否安装干净。Impacket 需要 Python 3.8+;镜像使用 3.12。smb_auth_attempt:某些客户端匿名枚举共享而不进行身份验证。这仍然会产生 smb_connect 和 smb_tree_connect。通过使用用户名映射共享来强制身份验证。src_host 是代理的地址,而不是真正的攻击者。通过时间戳与代理日志关联。NET_ADMIN(以及较新内核上的 BPF / PERFMON)来加载和附加 eBPF 程序。Compose 文件请求了这些范围的能力。如果您的主机或 Docker 版本拒绝它们,后备方案是在代理服务上使用 privileged: true,这更宽泛,应仅在范围能力不起作用时使用。internal 网络上。请保持这种状态。将每个诱饵容器视为可能已被攻破。bpf_sk_assign 保留攻击者的源 IP 到诱饵中,消除关联代理和诱饵日志的需要。MIT。请参阅 LICENSE。
| 容器 | OpenCanary 模块 | 监听端口 | 实际功能 |
|---|
ssh-decoy | ssh | 2222 | 通过 twisted.conch 进行真实 SSH 密钥交换。捕获每个用户名/密码对。 |
rdp-decoy | rdp | 3389 | 模拟启用 NLA 的服务器,总是返回登录失败,提取 mstshash 用户名。 |
smb-decoy | Impacket | 445 | 纯 Python SMB2/3 服务器。提供诱饵共享并以 JSON 格式记录连接和 NTLM 认证尝试。 |
| logtype | opencanary/logger.py 中的常量 | 含义 |
|---|
| 1000 | LOG_BASE_BOOT | 守护进程启动 |
| 4000 | LOG_SSH_NEW_CONNECTION | SSH 连接已打开 |
| 4001 | LOG_SSH_REMOTE_VERSION_SENT | 客户端发送其版本字符串 |
| 4002 | LOG_SSH_LOGIN_ATTEMPT | SSH 登录尝试(包含 USERNAME 和 PASSWORD) |
| 5000 | LOG_SMB_FILE_OPEN | SMB 文件已打开(包含 USER、SHARENAME、FILENAME) |
| 14001 | LOG_RDP | RDP 连接 / 登录尝试 |
| 层 | 知道真实源 IP? | 知道尝试了什么? |
|---|
eBPF 分类器(probe observed) | 是 | 否,仅 SYN 元数据 |
代理代理(session opened) | 是 | 否,仅字节计数 |
OpenCanary 诱饵(logtype 4002) | 否 | 是,凭据/文件 |