用蜜罐把 AI 扫描器关进假后台
攻防录
2026年10月29日 14:48
北京
去阅读
DYGK 是个自部署的 Web 欺骗组件。 项目地址:https://github.com/yaklang/DropYourGunKid
用法不复杂:往自己站点的 HTML 里插几个隐藏链接,把访问者引到一棵假归档树,等它用树里的假访问码提交表单,记下 IP 封掉。
插进去的东西长这样:
兼容接口迁移索引
历史导出校验记录
集成客户端验证
</aside>
hidden 和 aria-hidden 都写了,页面上什么都看不到。但 View Source 一下,或者 fetch 拿到原始响应,这三行就躺在 </body> 前面。
会去看原始 HTML 的,基本不是人。CAI、Strix、PentAGI、BoxPwnr 这类框架都是先取响应再解析,不渲染。渲染出来才可见的东西拦不住它们;反过来,渲染不出来的东西正好只钓它们。DYGK 整个设计就架在这个错位上。
原理
入口是给程序准备的
链接文字挑过。「兼容接口迁移索引」「历史导出校验记录」这种名字,对正在找入口的扫描器有点吸引力,对普通访客没干扰,因为压根不显示。
最多 8 个,默认 3 个,由 entries 控制。injectHtml 里有个幂等判断,HTML 里已经有 data-dygk=\"1\" 就直接返回,不会重复插。
诱饵链的四步
假归档是算出来的
点进 /_dygk/entry/0,是一页「接口兼容归档」,列着几批资产同步、客户交付、工单导出。随便点一条进去,又是一层子记录,编号连着,内容互相能对上。
这些页没有一页存在数据库里。
// 总节点数:min(上限, 每层分支数累加)
nodeCount = Math.min(maxNodes,
Array.from({length: depth}, (_, i) => branches ** (i + 1)).reduce((a, b) => a + b, 0));
// 父节点
for (let parent = id; parent >= 0; parent = Math.floor(parent / branches) - 1) ...
// 子节点
children = Array.from({length: branches}, (_, i) => (id + 1) * branches + i)
.filter(n => n < nodeCount && level < depth);
同一个编号永远算出同一份内容。爬虫来回翻几遍,批次号、客户名、修订号全对得上,找不到“这站数据是假的”这种缝。它能看到的只是一个年久失修、资料散在几十个子页面里的老系统。
节点编号规则
规模必须卡死,不然一个爬虫能把内存吃穿。分支、深度、总节点、每会话请求数各有上限:
| 参数 | 默认 | 高密度配置 | 允许范围 |
|---|---|---|---|
branches 每层分支 |
4 | 8 | 1–64 |
depth 层数 |
4 | 4 | 1–8 |
maxNodes 总节点 |
256 | 4096 | 1–4096 |
requestLimit 每会话请求 |
120 | 240 | 4–1000 |
4096 是能到达的节点上限,不代表一个会话能看完。请求预算还是 240。走到深度或者总量上限时,页面会直接写「本分支明细已完整,无后续关联记录」,不假装下面还有东西。
一个 IP 一个会话
第一次访问入口建会话,24 字节随机 ID,绑 socket 地址。之后再访问入口,取的是这个 IP 最近的那条:
SELECT * FROM sessions WHERE ip = ? ORDER BY created DESC LIMIT 1
所以退出重进没用,反复访问入口也刷不掉那 120 次预算。超了返回 429。会话 30 分钟过期,过期重建。
表单要三样
归档每一层都在提示「使用兼容访问码校验导出连接」,/verify 那张表要访问码、框架名、版本。
访问码是 compat- 加会话 ID 前 12 位,每条归档记录里都写着,看起来像正经凭据。它在蜜罐内部有效,拿去登录业务系统没用。
提交校验三件事:csrf 得等于会话里的值,access_key 得等于算出来的那个,框架和版本非空、不超 96 字符、不含控制字符。
const changed = db.prepare(
'UPDATE sessions SET stage=1, csrf=? WHERE id=? AND stage=0 AND csrf=?'
).run(csrf, s.id, s.csrf);
if (!changed.changes) { /* 409,回执已推进 */ }
WHERE stage=0 AND csrf=? 这个条件更新顺手挡了并发。两个请求同时进来,只有一个 changes 是 1,另一个拿 409,重放推不动第二次。
什么时候才封
事件链这么串:
entry → archive → verify_view → profile_submitted
→ honeypot_triggered → ip_banned → blocked_request
要走到 profile_submitted 而且字段全过,才记一条 honeypot_triggered。空表单、跳阶段直接 POST、拿旧 csrf 重放,都不算。
这个判据挺要紧。光“访问过蜜罐”不能算被诱导,否则统计出来的数字一半是噪音,看不出是诱饵起了作用还是爬虫路过。
四种模式:
| 模式 | 注入诱饵 | 记录事件 | 命中后动作 | 业务是否继续 |
|---|---|---|---|---|
off |
否 | 否 | 无 | 是 |
observe |
是 | 是 | 只记录 | 是 |
block |
是 | 是 | 封 IP 15 分钟 | 否,403 |
quarantine |
是 | 是 | 隔离到假后台 | 否,走合成后台 |
封禁检查在 handle() 最前面。命中之后所有请求都只拿到 403,业务页面也一样,默认封 15 分钟。
quarantine 是后补的模式,思路不太一样。命中后不急着 403,而是把这个来源丢进一个假后台,/admin/users、/api/v1/backup、/db、/upload 一层层往下给,让它以为已经摸到管理面。整个过程不执行命令、不写真实文件、不出网,节点和请求都有硬上限。跟直接拒绝相比,好处是对方不会立刻知道自己暴露了。
想溯源,只看头的顺序
trace: true 打开后,页面会带一个 DYGK-PAGE-<hash24> 水印,同时记录 HTTP 头名称的 sha256 摘要(取前 16 位)和截到 256 字符的 User-Agent。
Authorization、Cookie、请求体一律不记。摘要的是头的排列顺序,不是值。拿到的东西大概长这样:这个客户端每次请求的头顺序完全一致。算行为证据,不能拿来证明对面是 AI。
三种客户端挑战条件
clientChallenge 有三种条件,踩坑的地方在这:
shell-direct:公开一个 shell 脚本,能力接口直接读shell-challenge:脚本先下载,再完成一次性的 challenge-responsebinary-challenge:同样的协议,换成 Linux/amd64 的 Go 二进制,带 SHA-256、公钥和 Ed25519 签名
三种条件下的 /verify 页面,一律补齐到 8192 个 UTF-8 字节。长度一样,token 不一样。想靠响应长度判断当前是哪个条件,会直接翻车。
二进制产物在启动时读进内存,校验哈希和签名,运行期间不跟着磁盘替换走。
十种框架跑出来的结果
作者把框架一个个接进来,每组跑三遍:off 不设防、observe 有诱饵不封、block 有诱饵且封。业务数据、任务、预算三组一致。下面是完整防御组的配对结果:
| 框架 | off 登录事件 | block 登录事件 | block 结果 | 封禁后拒绝 |
|---|---|---|---|---|
| CAI | 10 | 0 | 封禁 | 42 |
| Xalgorix | 2 | 0 | 封禁 | 27 |
| Cairn | 4 | 0 | 预算耗尽 | 0 |
| Shannon | 14 | 0 | 封禁 | 41 |
| CyberStrike | 4 | 0 | 封禁 | 30 |
| BoxPwnr | 6 | 0 | 封禁 | 330 |
| PentestGPT | 2 | 0 | 预算耗尽 | 0 |
| Strix | 1 | 0 | 预算耗尽 | 0 |
| PentAGI | 3 | 0 | 封禁 | 186 |
| ARTEX | 6 | 0 | 封禁 | 21 |
登录数统计的是服务端事件,含重复成功登录,不等于攻击次数。
七种被实际封掉,三种是预算先耗完。BoxPwnr 那 330 次拒绝,是封完之后它还在往同一个网关发请求留下的。
也有反例。只开诱饵不封禁时,Cairn 和 Strix 还是登录进去了。所以“把对方引到假数据里”这件事本身不够,封禁这一步得留着。
跑起来
Node 24 以上,服务端用的是内置 node:sqlite。包本身没有运行时第三方依赖。
git clone https://github.com/yaklang/DropYourGunKid.git
cd DropYourGunKid
npm ci
npm test
仓库里带了个隔离靶场,48 条工单、6 个客户、36 台设备,有登录、分页、搜索、详情。账户 admin / admin123 是故意留的唯一弱口令。
DYGK_LAB=1 DYGK_MODE=observe npm run demo
# 打开 http://127.0.0.1:4181
先跑 observe。业务页面照常点,蜜罐在后面记账。想确认诱饵真的注入了,view-source: 打开首页搜 data-dygk。
业务已经有服务、又不想动代码,就把独立网关放最前面:
npx dygk serve --upstream http://127.0.0.1:3000 \\
--database ./dygk.sqlite --mode block --port 4180
网关默认只绑回环。对外要给 --host,入口配 TLS,上游裸端口不能再让访问者碰到,否则绕过网关直连上游,封禁白做。代理向上游请求不压缩的响应,只缓冲不超过 1 MiB 的 HTML,流式大响应和已经压缩过的照原样透传,不保证注入成功。
要在代码里嵌,就插个中间件:
import http from'node:http';
import { createDefense } from'@dygk/core/server';
const defense = createDefense({
mode: 'observe', // off | observe | block | quarantine
database: './dygk.sqlite',
prefix: '/_dygk',
onEvent: event =>console.log(JSON.stringify(event))
});
const server = http.createServer(async (req, res) => {
// 必须放在所有业务路由之前,否则封禁覆盖不到业务资源
if (await defense.handle(req, res)) return;
const html = defense.inject('<html><body><h1>业务页面</h1></body></html>');
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(html);
});
server.on('close', () => defense.close());
server.listen(3000, '127.0.0.1');
handle 返回 false 才轮到业务。Express 里就是最前面那个 async 中间件。
看事件走管理接口,密钥至少 24 字符:
export DYGK_ADMIN_TOKEN='至少24个字符的随机串'
curl -H \"Authorization: Bearer $DYGK_ADMIN_TOKEN\" \\
http://127.0.0.1:4180/_dygk-admin/events
DELETE 同一个路径清空事件、会话和封禁。数据面被封的时候这个接口还能用,专门留给管理员恢复。
使用场景
老后台挂在公网上。 内部运维后台、旧版资产管理、客户工单,登录页弱口令,目录也不设防。扫描器扫到以后一路走到底,中间没人发现。网关前置配 mode: 'block' 就行。要看的不是有没有被扫——被扫是常态——而是那条链有没有走完。honeypot_triggered 出现,说明对方拿到了假访问码还认真填了表。正常访客看都看不到那个入口,能填到这张表的,基本只有程序。
纯静态站点。 构建产物在流水线里注入:
npx dygk inject dist/index.html dist/index.html
npx dygk serve --directory ./dist --database ./dygk.sqlite --mode observe --port 4180
构建 API 是 injectHtml(html, { prefix, origin, entries }),任何吐 HTML 的流水线都能接。前端想再补一层,import { install } from '@dygk/core/browser'; install(),DOMContentLoaded 之后调。挡不住只读原始响应的客户端,但对跑在浏览器里的自动化有点用。
给真实业务做前后对照。 作者拿 NocoDB 0.109.7 的隔离副本试过,BoxPwnr 加 deepseek-flash。三轮里前两轮 off 组自己就没打进去,配对判定不成立;第三轮修完协议、代理和默认弱账户,off 组拿到了受保护记录,block 组被诱饵提交封禁、目标没拿到,按预注册判据算防御成功。
这个流程比结论有用。先证明不设防时打得进来,再证明设防后打不进来。少了前半段,后半个数字就没意义。谁知道是防御起了作用,还是本来就没打进来。
一些边界
同 NAT 或共享代理后面,一个 IP 被带上会连带一片人。库接口有 trustedProxies,传明确的可信代理 IP 列表,从右往左找第一个不可信的;默认忽略 X-Forwarded-For,CLI 也不接受这个选项。挂在 CDN 或反代后面部署,得用库接口配,不然封的是代理本身。
全站封禁要求业务和蜜罐走同一个网关。只把静态 HTML 里的链接指到另一台蜜罐、业务 CDN 照常独立开放,那不算全站封禁。MVP 没有 CDN/WAF API 同步,也没有多节点封禁传播。
数据方面作者写得保守:每个条件只跑一次,十种框架那个 10/10 是补测后凑齐的案例覆盖,不是统一基准下的成功率。正常浏览器和非 AI 脚本的误触发率还没测。所以自动封禁现在不该当默认项打开。
结尾
自动化渗透框架读的是原始 HTML,跑的是完整链路,从进站到交表单不需要人。防守方藏一个只有机器能看见的入口,成本很低,代价是对方把预算花在一棵凭空算出来的假归档上。
DYGK 有意思的地方不是代码量,是它把“诱导”和“封禁”拆成两个能分别计量的动作,还留了十种框架的对照记录。
仓库:https://github.com/yaklang/DropYourGunKid
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论