willysnow/wisp
GitHub: willysnow/wisp
单文件网络蜜罐传感器与自托管控制台,用静态二进制简化部署,并覆盖 LLM、Kubernetes、MCP 等新一代攻击面。
Stars: 1 | Forks: 0
# wisp
一个单文件网络蜜罐传感器。将其部署在内网网段中,等待有东西触碰它,
即可收到包含凭据、提示词或入侵者尝试访问路径的告警。
## 为什么会有这个项目
[OpenCanary](https://github.com/thinkst/opencanary)(BSD-3-Clause,由 Thinkst 开发)
证明了这种模式的有效性,它比本项目更完整,并且是对该领域的一项真正贡献。`wisp` 并非对它的批评——而是坚信有两件事值得重做:
1. **部署。** OpenCanary 需要 Python 3.10+、Twisted、Scapy、pcapy-ng,而且——对于 SMB——需要一个正常工作的 Samba 安装,并通过 `full_audit` VFS 模块写入 syslog 供 OpenCanary 读取。`wisp` 只是一个静态二进制文件。
2. **攻击面。** 2026 年内网上的入侵者会尝试使用 Kubernetes API、Docker socket、云 IMDS,以及任何未经认证就搭建起来的 LLM 基础设施。最后一个正是 `wisp` 的切入点,因为没有其他工具覆盖这一块。
OpenCanary 做的其他所有事情,目前它都做得更好,因为至少它实现了这些功能。
## 诚实的对比
OpenCanary 提供了 21 个协议模块。`wisp` 重新实现了其中的 **12** 个,外加 3 个 OpenCanary 没有的诱饵。
| | OpenCanary | wisp 目前 |
|---|---|---|
| 协议模块 | 21 | **12** + 3 个新诱饵 |
| 本项目已实现 | — | `ssh`, `http`, `https`, `telnet`, `ftp`, `redis`, `tftp`, `ntp`, `git`, `mongodb`, `llmnr`, TCP banner |
| 仍然缺失 | — | SMB, RDP, MySQL, MSSQL, VNC, SNMP, SIP, HTTP 代理, portscan |
| SMB | 是,通过外部的 Samba | **未实现** |
| LLM / MCP / Kubernetes 诱饵 | 否 | **是**(`ollama`, `mcp`, `k8s`) |
| 告警 | 文件, syslog, HPFeeds, 邮件, webhook, + 独立的去重守护进程 | JSONL, syslog, 邮件, LINE, webhook (Slack/Teams/Discord), **内置去重** |
| 集群控制台 | 无 | **包含,可自托管** |
| 安装 | Python + Twisted + Scapy(SMB 还需要 Samba) | 一个静态二进制文件 |
| 平台 | Linux 优先,多个模块需要 root | Go 可交叉编译的任何地方 |
通用的 TCP banner 捕获器可以在连接级别覆盖任何额外的端口——
足以发现扫描或探测,但无法像真正的模拟器那样捕获凭据。VNC 仍然作为 banner 捕获器提供,并在上表中计为缺失,因为实际情况就是如此。
如果你今天需要全面覆盖,请运行 OpenCanary。如果你想要 LLM 诱饵,或者 Python/Samba 的依赖链是你根本不愿部署蜜罐的原因,那就运行 `wisp`。
## 目前模拟的功能
| 服务 | 默认端口 | 捕获的内容 |
|---|---|---|
| `ssh` | 2222/tcp | 用户名、密码、公钥指纹、客户端版本 |
| `http` | 8080/tcp | 管理面板凭据、探测的路径、user agents |
| `https` | 8443/tcp | 同上,外加 **SNI** ——他们期望在这里找到的名称 |
| `telnet` | 2323/tcp | 用户名和密码(去除了 IAC 协商) |
| `ftp` | 2121/tcp | 用户名、密码以及登录后的命令意图 |
| `redis` | 6379/tcp | AUTH 凭据**以及完整的命令序列** |
| `tftp` | 6969/udp | 请求的文件名以及是读取还是写入 |
| `ntp` | 1123/udp | 客户端请求,以及 **mode 7 `monlist` 放大侦察** |
| `git` | 9418/tcp | 请求的**仓库路径**,以及他们是否打算进行 push |
| `mongodb` | 27017/tcp | 用户名和**可离线破解的 SCRAM 证明**、驱动程序和应用程序名称 |
| `llmnr` | 出站 | **网段上的主动投毒者**,以及它声称的地址 |
| banner | 任意 | 发送的前几个字节,用于没有真正模拟器的端口 |
| `ollama` | 11434/tcp | **模型列表侦察,以及发送到你的“GPU”的实际提示词** |
| `k8s` | 6443/tls | **被盗的 service-account bearer token**、客户端证书、探测的资源 |
| `mcp` | 8931/tcp | **连接的 agent,以及它带参数进行的 tool calls** |
### 其他产品都没有的三个诱饵
`ollama`、`k8s` 和 `mcp` 覆盖了入侵者在 2026 年网络上真正会触及的攻击面,并且每个都捕获到了连接日志无法捕获的内容:
- **`ollama`** 记录提示词。未经认证的推理端点是一个免费的 GPU、一个跳板,并且——当连接到内部 RAG 时——一条数据泄露路径。
- **`k8s`** 返回 `403`,而不是 `401`。这就是启用了 anonymous-auth 的集群的行为,两者的区别很重要:`401` 意味着“走错门了”并结束交互;而 `403` 意味着“门是对的,凭据错了”,因此攻击者会用被盗的 token 重试。知道*哪个 service account* 被攻破,比知道有人敲门要有价值的得多。
- **`mcp`** 记录 agent 自己的 `clientInfo`(比 user-agent 强得多的标识符)以及每次 `tools/call` 的参数。公布的工具是故意设下诱饵的,所以他们选择的工具能告诉你他们想要什么:
mcp tool_call tool=query_customer_db
arguments={"query":"SELECT email, credit_card FROM customers LIMIT 5000"}
这三个诱饵会给出应答而不是拒绝,因为合理的回复会让攻击者继续交流,而随后的每一条消息都是更多的情报:
- **redis** 返回 `+OK`,因此脚本化的接管会在你监视的地方运行它的全套 playbook。真实的攻击看起来像 `CONFIG SET dir /root/.ssh` →
`CONFIG SET dbfilename authorized_keys` → `SET x "ssh-rsa …"` → `SAVE`,这四条命令都会落到你的日志里。
- **ollama** 返回一个模型列表和一条拒绝消息,因此扫描器会发送真实的提示词。
- **ntp** 回答普通的 mode 3 客户端请求。
实际上什么都不会被授权——没有文件被写入,没有命令被执行,每个凭据都会被拒绝。
**`ntp` 从不回答 mode 7(`monlist`)。** 回答会将传感器变成一个正常工作的 DDoS 放大器,指向攻击者伪造的任何地址。它记录探测并保持沉默;这一属性由测试保障。
**`llmnr` 从不回答查询。** 它是唯一一个不是诱饵的模块:它组播一个对不存在的主机名的请求并等待。正常的网络会用沉默来回答,所以收到回复就是存在投毒者的证据——Responder、
Inveigh 等工具会回答所有请求,因为回答一切正是它们的工作方式。我们自己回答一个本质上就*等于*那种攻击,而一个会实施它所检测到的攻击的检测器根本不是检测器。这一点同样由测试保障。
它默认是关闭的,因为它是唯一一个会自行向网络发送数据包的模块。
**`mongodb` 声称需要身份验证**,而不是假装完全开放,原因与 `k8s` 回答 403 相同。一扇锁着的门会引诱人提供钥匙:驱动程序接着会运行 SCRAM,这会立即交出用户名,并且——一旦客户端发送了其证明——一个可以像捕获的 NetNTLMv2 响应那样被离线破解的值。任何内容都不会被接受;每一次对话都以 `AuthenticationFailed` 结束。
Ollama 传感器是其他产品都没有的。一个未经认证的推理
端点是一个免费的 GPU、一个跳板,并且——当连接到内部 RAG 时——一个数据泄露面。该模拟会合理地回答而不是报错,
因为获得模型列表的扫描器通常接下来会发送真实的提示词,
而该提示词会用攻击者自己的话告诉你他们想要什么。
## 快速开始
```
go mod tidy
go run ./cmd/wispd
```
不需要配置文件——它在默认配置下运行。要进行自定义:
```
cp wisp.example.yaml wisp.yaml
go run ./cmd/wispd -config wisp.yaml
```
然后触发它:
```
ssh -p 2222 root@localhost
curl -s localhost:11434/api/tags
curl -s localhost:11434/api/generate -d '{"model":"llama3.2","prompt":"cat /etc/shadow","stream":false}'
```
## Docker
一个 `Dockerfile` 构建了两个镜像,并且都是 distroless 的——没有 shell,没有
包管理器,没有任何可以作为跳板的东西。传感器是最有可能被故意攻击的容器,因此它携带的东西最少。
```
docker build --target sensor -t wisp/sensor .
```
```
docker build --target console -t wisp/console .
```
单主机评估栈(在生产环境中,控制台不应该位于你预期会被攻击的网段中):
```
docker compose up --build
```
关于在容器中运行蜜罐,有两件事比其他事情更重要:
- **在 Linux 上使用 host networking。** 使用发布的端口时,Docker 会将
客户端的地址重写为网桥网关,而一条声称每次入侵都来自 `172.17.0.1` 的告警是任何人都无法采取行动的。在 Docker Desktop
(macOS/Windows)上,这种重写是不可避免的——在那里映射端口并视
源 IP 为不可靠的。
- **保留数据卷。** 它们保存了 SSH 主机密钥、诱饵 TLS
证书、控制台数据库和操作员账户。每次重启都会改变的密钥材料,对于任何连接两次的人来说,都等同于昭告天下这是一台蜜罐。
两个容器都以非 root 用户身份运行,具有只读的根文件系统,
所有权限被丢弃,并设置了 `no-new-privileges`。端口是非特权
默认端口,因此不需要重新授予任何权限。
控制台镜像没有 shell 来运行健康探测,因此二进制文件探测
自身——`wisp-console healthcheck` 被配置为镜像的 `HEALTHCHECK`,
这也是编排器用来判断控制台是否存活的方式。
## systemd
两部分的 unit 文件都在 [`deploy/systemd/`](deploy/systemd) 中,沙箱化达到
与容器相同的标准:无权能,除了它们自己的状态目录外没有任何可写
的地方,包含 seccomp 过滤器,并且没有提升至 root 的路径。
传感器在设计上就需要解析恶意输入,而 unit 文件不应该依赖于代码
的绝对正确。
```
sudo install -m 0755 wispd /usr/local/bin/wispd
sudo useradd --system --no-create-home --shell /usr/sbin/nologin wisp
sudo install -m 0644 deploy/systemd/wispd.service /etc/systemd/system/
sudo systemctl enable --now wispd
```
`StateDirectory` 和 `ConfigurationDirectory` 会以正确的所有权创建 `/var/lib/wisp` 和
`/etc/wisp`,因此不需要 `chown` 步骤。完整的说明,
包括控制台以及如何在不授予该权能的情况下绑定特权端口,都在
[`deploy/systemd/README.md`](deploy/systemd/README.md) 中。
## 输出
两个输出端,默认均开启。供人类查看的控制台:
```
14:22:07 ollama prompt 10.0.3.44:51188 -> :11434 model=llama3.2 path=/api/generate prompt=cat /etc/shadow
```
……以及供机器读取的 `events.jsonl` ——每行一个 JSON 对象,可供 Vector、
Filebeat 或 SIEM 直接使用:
```
{"time":"2026-07-24T14:22:07.113Z","node":"wisp-01","service":"ollama","kind":"prompt","src_ip":"10.0.3.44","src_port":51188,"dst_port":11434,"data":{"model":"llama3.2","prompt":"cat /etc/shadow"}}
```
## 控制台
一组传感器写入各自本地文件并不构成一个监控系统。
`wisp-console` 是使其成为监控系统的部分:传感器通过 HTTPS 携带 bearer token 传输数据,
并且每条告警都会汇聚到同一个地方。
它是自托管的,除了一个可写文件外不需要任何其他东西——通过一个纯 Go 实现的驱动使用 SQLite,因此控制台也是一个单一的静态二进制文件。
```
go build -o wisp-console ./cmd/wisp-console
./wisp-console sensor add sensor-01
```
这会打印出一个 token ——仅一次。将其放入传感器的配置中:
```
log:
file: events.jsonl
console: true
remote:
url: "https://console.example.com"
token: "wisp_..."
```
然后运行控制台:
```
./wisp-console -addr :8000
```
每个传感器都有自己的 token,并且 **节点名称来源于 token,而不是
来源于请求体**。传感器无法伪装成另一个传感器,因此追查告警的操作人员永远不会被引向错误的机器。使用以下命令管理它们:
```
./wisp-console sensor list
```
```
./wisp-console sensor revoke sensor-01
```
为现有节点重新运行 `sensor add` 会签发一个新的 token 并使旧 token 失效——这既是轮换也是“我丢失了 token”。数据库只存储 token 的 SHA-256 哈希值,因此被盗的控制台数据库不会泄露任何有效的凭据。
为了兼容性,仍然可以使用单一共享 token(`-token` 或
`WISP_CONSOLE_TOKEN`),但控制台在启动时会发出警告:使用共享 token 时,节点名称会回退为 payload 中声明的任何内容,任何持有该 token 的人都可以伪造归属于任何传感器的事件。
然后在浏览器中打开控制台。凭据事件(`login_password`、
`auth_attempt`,……)会被高亮显示;点击传感器、服务或源 IP
会过滤视图。
### 登录
UI 在登录之后,没有任何设置可以关闭它。将
接入限制在 token 之后却仪表板敞开大门将起不到任何保护作用——
仪表板是所有捕获的密码、提示词和被盗 token 最终汇聚的地方。
在首次启动时,控制台会创建一个操作员账户并打印一次
密码:
```
created console operator "admin" with password: QOavm-F81rHgpHHQuX-Cqv48
```
从 CLI 管理账户:
```
./wisp-console user add alice
```
```
./wisp-console user list
```
`user add` 和 `user passwd` 会生成一个密码并打印一次;传递
`-password-stdin` 来提供你自己的密码(至少 12 个字符):
```
printf '%s\n' "$NEW_PASSWORD" | ./wisp-console user passwd alice -password-stdin
```
更改密码会使该账户拥有的所有会话登出——密码更改通常
*因为*它泄露了,而让旧会话继续保持登录状态将使这一切
毫无意义。默认情况下,会话持续 12 小时(`ui.session_ttl`),存在于
数据库中,因此重启不会让所有人登出,并且以哈希形式存储,因此
被盗的数据库不会泄露有效的访问权限。
传感器 token 和操作员登录是特意分开的凭据:位于
敌对网段的传感器除了写入事件外什么也做不了。它的 token
无法打开 UI,而 UI 会话也无法传输事件。
在反向代理后面,设置 `ui.trust proxy: true`,以便登录速率限制能识别
真实的客户端地址,而不是代理的地址——否则一个攻击者
失败的登录会把所有人都锁在外面。
### TLS
控制台自行终结 TLS——不需要反向代理。传感器发送
捕获的凭据及其 bearer token,操作人员发送密码;
这些内容都不应该以明文形式跨越网络。
```
tls:
mode: self-signed # off | self-signed | file | acme
cert_file: console-cert.pem
key_file: console-key.pem
domains: ["console.internal"]
```
**`self-signed`** 是大多数控制台的正确选择,它们通常部署在
没有公共 DNS 名称的内网网段中。证书生成一次并
保留——每次重启都更改的证书是无法被固定的(pin)——并且其指纹
在启动时打印:
```
TLS: self-signed certificate
SHA-256 51:49:55:A1:64:D0:50:F0:DD:CE:22:40:A2:EA:D3:A6:...
```
将其提供给传感器,这样就不必使用“跳过验证”(skip verification):
```
log:
remote:
url: "https://console.internal:8000"
token: "wisp_..."
fingerprint: "51:49:55:A1:..." # or ca_file: console-cert.pem
```
带有 pin 的传感器拒绝向任何其他对象传输数据——包括
对连接重新签名的 TLS 代理。(这并非假设:在运行
Norton Web Shield 的机器上,被固定的传感器正确地拒绝与
同一主机上的控制台通信,因为它拿到的是 Norton 的证书,而不是
控制台的。)`insecure_skip_verify` 依然存在,并在文档中作为实验室设置
被记录,它将把捕获的每一个凭据发送给任何响应连接的人。
**`acme`** 为公共名称从 Let's Encrypt 获取证书。它
需要 `accept_tos: true`——代表操作人员同意 CA 的条款
不是软件该做的决定。端口 80 是可选的:如果没有 `http_addr`,颁发
将使用基于 443 端口的 TLS-ALPN-01。设置 `http_addr: ":80"` 以提供
HTTP-01 验证并将纯 HTTP 重定向到 HTTPS。
对于前面已经有代理的部署,**`off`** 依然可用,
但控制台在每次启动时都会发出强烈的警告。
### 搜索、分页和导出
事件列表每页显示 100 行,搜索框将自由文本与
每一列进行匹配,**包括 JSON 数据 blob**——这是有趣
字符串所在的地方。搜索 `hunter2` 会找到使用它的登录记录;搜索
`/etc/shadow` 会找到请求它的提示词;搜索 `10.0.0.9` 会找到
该地址触碰过的所有记录。点击传感器、服务或源 IP 仍然
会进行过滤,而搜索会在已过滤的内容范围内进一步缩小范围,而不是
替换它。
导出采用与启动它的页面相同的过滤器,格式为 CSV 或
换行符分隔的 JSON——与传感器 `events.jsonl` 的格式相同,因此
任何已经指向该文件的工具也能读取此导出。
```
/export.csv?q=hunter2&hours=168
```
关于 CSV 有一个细节值得了解:其中的每个字段都是攻击者控制的——
用户名、探测的路径、提示词。当电子表格打开该文件时,以 `=` 开头
的值会被作为公式执行,因此仔细挑选用户名的攻击者
可能会在读取导出文件的分析师的机器上实现代码执行。这些值在输出时会被加上一个前导撇号;payload
仍可作为证据阅读,只是不会被执行。
### 当传感器变得沉默时
传感器无法报告自己的死亡。如果有人发现并阻止了它,
它所做的最后一件事就是保持沉默——而一个沉默的传感器看起来就像是一个什么都没发生的网络。控制台是唯一能区分
这两者的地方:
```
sensors:
silence_after: 30m
check_interval: 1m
```
越过阈值会引发 `sensor_silent` 事件:存储在时间线中,
推送到每个通知器,并在传感器列表中标记为红色。重新上线会
引发 `sensor_returned`,因为凌晨 3 点被叫醒的人不应该
猜测它是否仍然处于宕机状态。每次状态转换时各报告一次,因此
保持宕机状态的传感器不会每分钟都呼叫任何人。
这是 wisp 中唯一因为什么都没发生而触发的告警,也是
最可能至关重要的告警:以杀死传感器为开端的入侵
在其他情况下是不可见的。
### 保留
如果放任不管,数据库将永远增长,其大小由扫描你传感器的
人决定——磁盘填满的控制台将停止记录导致其填满的入侵。在 `console.yaml` 中设置策略:
```
retention:
events: 90d # maximum age; accepts d and w as well as h/m/s
max_events: 1000000 # hard cap whatever the age; oldest go first
interval: 1h # how often the policy is applied
```
两个限制均适用,以最先触发的为准。计数上限是年龄限制无法提供的基础保障:
在持续扫描下的传感器一个下午就能产生数月的
事件。清除后的空间将返还给文件系统——SQLite 会重用释放的页面,
但从不会自行缩小文件,因此控制台在足够大规模的清理之后会对其进行重建。
默认设置为无限制,因为静默丢弃操作员的证据
是本末倒置的做法——但控制台在启动时会发出警告,直到设置了策略。
根据设计,交付是尽力而为的:`Emit` 从不阻塞服务 goroutine,并且
当控制台不可达时,事件会排队并最终被丢弃。
因为报告通道停滞而停止响应网络的传感器,
要比丢失遥测数据的传感器更糟——停滞的服务是一个
可被检测到的特征。
### 通知
没人打开的仪表板不是监控。复制 `console.example.yaml` 到
`console.yaml` 以通过 **电子邮件**、**LINE** 或 **webhook**(JSON、
Slack、Teams、Discord)发送告警。
有两条规则决定了什么内容会被发送,这两条都很重要:
**只有有意义的事件类型才会通知。** 提供的凭据(`login_password`、
`auth_attempt`,……)和表达的意图(`prompt`、`tool_call`、`command`,……)。
纯粹的连接和版本探测会被存储但不会被推送——它们
是调查的上下文,而不是在凌晨 3 点叫醒某人的理由。
**重复项会按 `sensor|service|kind|source-IP` 被抑制**,抑制期可配置
(默认 15 分钟)。窗口期结束后的下一次告警会说明有多少相似事件
被折叠了:`ssh login_password from 10.0.0.9 (+47 similar suppressed)`。
不同的源 IP 始终被视为新告警——抑制机制绝不
能掩盖新的攻击者。
在本地端到端运行中,40 个存储的事件产生了 7 条通知——来自
一个源的重复 telnet 暴力破解从 4 条告警合并为 1 条。如果没有这个
机制,单次端口扫描就会让你的通道静音,从而错过下一次
真正的入侵。
提供 LINE 是因为在台湾和日本,运维团队实际
就在使用
标签:AMSI绕过, DLL 劫持, EVTX分析, StruQ, 单二进制, 大语言模型, 威胁检测, 安全, 日志审计, 网络蜜罐, 请求拦截, 超时处理