简介:cookie-stealer 是一套面向前端安全学习者的轻量级 Cookie 安全演示工具,适合正在了解浏览器 Cookie 机制、Web 安全基础与渗透测试入门的人群,用于在受控环境中理解 Cookie 被读取与传输的原理及潜在风险。压缩包共 7 个文件,约 33KB,以 JavaScript 脚本为主体,辅以 JSON 配置、HTML 页面、Python 服务端脚本和 Markdown 说明文档,同时覆盖浏览器扩展端与本地服务端两部分,便于对照阅读工具结构与数据流向。目前已有 186 人学习,包体小巧、依赖简单,上手门槛较低。读者可通过脚本与说明文档,了解 Cookie 的存储位置、跨域携带条件以及前端脚本访问限制等知识点,并据此认识会话劫持的常见路径与防护思路,为后续 Web 安全学习与代码审计打下基础。
1. 一个三行脚本就能读走会话的资源,我为什么把它当自查清单用
第一次看到 cookie-stealer 这个 JavaScript 工具时,我并没有把它当成"攻击者的武器"来看。它的核心动作只有一件事:在当前页面上下文里把document.cookie读出来,再证明这条路走得通。换句话说,它测的不是某个网站的漏洞,而是测你的 Cookie 到底有没有设防。我拿它做的第一件事,是把自己维护的几个后台页面挨个跑了一遍,结果有三处能直接读到会话标识——那一刻比看任何扫描报告都直观。这篇东西写给两类人:一类是负责把自己系统守住的人,想搞清楚"到底哪一步漏了";另一类是想学攻防但不想只会抄工具的人,想知道判断依据在哪。下面不谈玄学,全是我自己搭环境、逐条验证、再回头加固的过程。
2. Cookie 泄露的几条路径:先搞清楚入口在哪
在动手验证之前,得先建立一个基本判断:Cookie 泄露从来不是"一个工具的问题",而是"一条链路里某一环没有设防"。cookie-stealer 这类脚本本质上只是一个读取器,它能不能拿到东西,完全取决于三个前置条件是否成立——页面存在脚本注入点、目标 Cookie 没有 HttpOnly 标记、以及脚本运行上下文和 Cookie 的作用域有交集。这三个条件缺一个,工具就跑不动。所以真正值得花时间的不是工具本身,而是把这三条路径逐个拆开看清楚,知道每一条的触发条件和观测方法。
我一般会按"入口 → 凭据 → 边界"的顺序排优先级。入口决定攻击者能不能执行代码,凭据决定拿到了能维持多久,边界决定影响半径有多大。这个顺序不是拍脑袋来的,而是因为绝大多数真实事件里,第一环成立之后后面两环往往也成立——开发者往往在同一个页面上同时犯了注入过滤不严和 Cookie 缺属性两个错误。把三条路径分开看,是为了在加固时能对应到具体的配置项,而不是笼统地说一句"加强安全意识"。
2.1 XSS 注入点为什么是最短的那条路
脚本注入是这条链路上最短的一段,因为只要攻击者的 JavaScript 能在你的页面域下执行,同源策略就反过来变成了帮凶——它挡不住同域脚本读取同域 Cookie。常见的注入形态无非三类:反射型(参数被原样拼进响应)、存储型(内容落库后回显)、DOM 型(前端用innerHTML、document.write、eval之类的危险拼接把不可信数据当代码执行)。前两类在服务端能拦,第三类经常被忽略,因为它根本不经过服务端。
DOM 型注入最难查的地方在于,它在你抓包时看不到任何异常请求,全部发生在浏览器里。我一般会重点看几个位置:URL 参数被直接塞进innerHTML的地方、前端路由把 hash 内容当模板渲染的地方、以及富文本编辑器回显链路。这些位置的共同特征是"数据从 URL 或本地存储来,直接进了解析上下文"。判断依据很简单:搜代码里所有把变量拼进 HTML 字符串的位置,逐个确认这个变量是否可能被外部控制。
这里有个容易被低估的细节:即使你的主站过滤做得很好,只要有一个子域或者一个老旧的营销落地页存在注入点,攻击者就能在那个域下执行脚本。而 Cookie 的Domain属性一旦被设为父域(比如.example.com),所有子域都能读到它。这就是为什么很多团队主站固若金汤,却栽在一个三年前上线、没人维护的活动页上。
2.2 HttpOnly 缺位:一次泄露长期有效
HttpOnly 这个属性的作用非常明确:标记之后,document.cookie读不到这条 Cookie,只有 HTTP 请求头里会带上它。就这么一个开关,直接决定了 XSS 能不能升级成会话劫持。我在自查时见过最常见的情况是——登录接口返回的会话 Cookie 没有加 HttpOnly,理由是"前端要读出来做点判断"。这种需求十有八九是设计问题,因为前端根本不需要知道会话标识是什么,它只需要知道"当前是否已登录",而这个信息完全可以让服务端在响应体里返回一个布尔值或者用户信息对象。
比"没加 HttpOnly"更隐蔽的是"加了但加错了地方"。比如一个系统同时存在新旧两套会话机制,老的 PHP 会话 Cookie 加了标记,新接入的 JWT 存在另一个自定义 Cookie 里却没加;或者 CSRF Token 单独放在一个可读 Cookie 里,然后被当成了会话凭据使用。我在核对时习惯把浏览器开发者工具里 Application → Cookies 那一栏整个展开,逐条看每个 Cookie 的 HttpOnly、Secure、SameSite 三列,而不是只看名字像会话的那一条。
从攻击者视角看,HttpOnly 缺位意味着"一次注入、长期有效"。因为会话 Cookie 通常有较长的有效期,攻击者拿到之后可以在自己环境里重放,不需要再触发第二次注入。这也是我建议把会话有效期压短、配合刷新令牌轮换的原因——不是为了防住第一次泄露,而是把泄露之后的窗口从"几个月"压到"几十分钟"。这个成本和收益的比值,比任何检测手段都划算。
2.3 第三方脚本与子域:最容易被忽略的信任边界
这条路径被讨论得最少,但我在实际排查中遇到的次数不少。页面引入的第三方脚本(统计、客服、A/B 测试、广告)运行在你的域下,拥有和你自己代码完全相同的权限,包括读取非 HttpOnly Cookie。你无法审计这些脚本的每一行代码,也无法保证它们的 CDN 不被污染。一旦其中任何一个环节出问题,等价于你自己的页面出现了注入点。
子域的问题同理。很多团队的测试环境、文档站、旧版后台挂在不同子域上,安全水位参差不齐。只要其中一个子域存在 XSS,而会话 Cookie 的Domain设在父域,攻击者就能在那个弱子域上读走主域的会话。我一般的判断依据是:把所有子域列出来,找出"最近半年没有代码变更"和"没有接入统一的安全响应头"的那几个,它们就是风险最高的。
收敛这条边界的办法有两个方向。一是收紧 Cookie 的作用域,把Domain属性去掉,让它只对当前主机生效,同时用__Host-前缀强制浏览器校验;二是给第三方脚本加上integrity属性做子资源完整性校验,并尽量把它们放到独立子域下隔离。前者是根治,后者是缓解。两个都做,成本并不高,但对这条路径的封堵效果是决定性的。
3. 自建环境复现:从能不能读到读完能干什么
看懂了路径,接下来就是验证。这一步必须严格限定在你自己拥有和控制的环境里——本地起的服务、你自己名下的测试域名、或者一个隔离的容器。我不建议在任何非授权目标上做这件事,一是法律上说不清,二是你测出来的结果也不可信(你不知道对方有没有 WAF、有没有别的检测在干扰)。复现的目的不是证明"我能黑掉谁",而是证明"我自己的系统在哪个环节没设防"。
这套流程我走过很多遍,后来固化成了三个步骤:搭靶场、最小验证、记基线。第三步最容易被跳过,但它恰恰是最有价值的——因为加固之后你要再跑一次,没有基线就没法证明加固有效。很多人做完一轮修复就说"应该好了",结果三个月后同一个问题换个页面又出现了。
3.1 搭一个只属于自己的靶场
靶场不需要复杂,一个能设置 Cookie、能接收 URL 参数并回显的页面就够了。关键是这个页面必须由你自己控制,且不接入任何真实数据。我通常会在本地起一个静态服务,然后故意留一个把 URL 参数直接拼进innerHTML的位置,用来模拟 DOM 型注入;再加一个接口,返回带和不带 HttpOnly 的两种 Cookie,用来做对照。
搭的时候有个细节要注意:浏览器对Secure属性的校验很严格,HTTP 环境下带Secure的 Cookie 会被直接丢弃。所以如果你想验证Secure的行为,本地得配一个自签证书走 HTTPS,否则你会得到"设置了但没生效"的假象,白折腾半天。这也是我早期翻车最多的地方之一。
另外,测试用的浏览器配置最好独立——单独的用户目录、关闭所有扩展、不用日常账号。因为你不知道自己装的某个插件会不会顺手改 Cookie 或者注入脚本,这些干扰会让你对结果的解读完全跑偏。我一般会开一个干净的用户配置文件专门做这类验证,做完就整个删掉。
3.2 最小化验证片段:只验证可读性,不碰外发
验证的目标只有一个:确认在当前上下文里,目标 Cookie 是否可读。读到了就说明这条链路成立,读不到就说明 HttpOnly 起了作用。我不做任何外发动作——不发请求、不写远程、不留痕。原因很实际:一旦引入外发环节,你就得处理跨域、CORS、日志留痕一堆问题,而这些和"验证可读性"这个目标毫无关系。
// 仅用于自建环境验证:判断会话 Cookie 在当前上下文是否可读 // 不发送任何网络请求,结果只输出到本地控制台 function auditCookieReadability(cookieName) { // document.cookie 返回的是 "k1=v1; k2=v2" 形式的字符串 const raw = document.cookie; // 空字符串有两种可能:没有任何可读 Cookie,或全部被 HttpOnly 保护 if (!raw) { console.log('[audit] 当前上下文无可读 Cookie(可能是 HttpOnly 生效)'); return { readable: false, raw: '' }; } const pairs = raw.split(';').map(s => s.trim()); const hit = pairs.find(p => p.startsWith(cookieName + '=')); if (!hit) { console.log(`[audit] 未找到名为 ${cookieName} 的可读 Cookie`); return { readable: false, raw }; } // 只报告"存在且可读",不打印具体值,避免把凭据落到控制台历史里 console.log(`[audit] ${cookieName} 可被脚本读取,长度 ${hit.length}`); return { readable: true, raw }; } auditCookieReadability('sessionid');这段代码的关键点有三个。第一,document.cookie拿到的永远只是"未被 HttpOnly 标记"的那部分,所以它本身就是一把尺子——能读到就说明没设防。第二,代码里刻意不打印 Cookie 的值,只打印长度,因为控制台输出会被历史记录、崩溃报告、录屏工具带走,这本身就是一个二次泄露渠道。第三,cookieName这个参数要换成你系统里真实的会话 Cookie 名,一般可以在开发者工具的 Application 面板里直接看到。
跑完之后你会得到两种结果。如果返回readable: false且原始串为空,说明这条路径已经被 HttpOnly 堵死了,可以进入下一项。如果返回readable: true,那就得顺着查——这条 Cookie 是谁设置的、为什么没加 HttpOnly、前端有没有真的依赖它。大多数情况下答案是"没人依赖,只是当初忘了加"。
3.3 结果记录表:把一次验证变成可对比的基线
验证做完如果只留在脑子里,下次再查就得重来一遍。我的做法是每次都记一张表,字段固定,加固前后各跑一次做对比。这张表不需要多复杂,重点是字段要能对应到具体的配置项,这样修复的时候才知道改哪里。
| 检查项 | 观测点 | 加固前 | 加固后 | 判定依据 |
|---|---|---|---|---|
| 会话 Cookie 是否可脚本读取 | document.cookie输出 | 可读 | 不可读 | 输出为空或无法匹配到 Cookie 名 |
| HttpOnly 标记 | 开发者工具 Cookies 面板 | 缺失 | 已设置 | HttpOnly 列为勾选状态 |
| Secure 标记 | 同上 | 缺失 | 已设置 | HTTPS 下才下发 |
| SameSite 取值 | 同上 | 未设置 | Lax | 跨站请求是否携带该 Cookie |
| Cookie 作用域 | Domain 属性 | 设为父域 | 未设置 Domain | 子域能否读取 |
| 页面是否存在注入点 | 手动输入特殊字符回显 | 存在 | 已转义 | 特殊字符是否被原样解析 |
这张表的价值在于它把"感觉安全了"变成"哪一项从什么变成了什么"。我在给团队做复盘时发现,凡是能拿出这种前后对比的,加固基本都是真做了;凡是只有一句"已经修复"的,八成只改了一个页面。表里最后一行"页面是否存在注入点"是最难自动化的,只能手工试,但它是整条链路的第一环,优先级最高。
4. 加固清单:把读取 Cookie 的成本抬到最高
到这里,攻击路径和验证方法都清楚了,剩下的就是把口子一个个堵上。加固这件事我不喜欢列一长串"最佳实践",因为没人看得完。我一般按"服务端属性 → 响应头策略 → 检测能力"三层来做,每层只留最关键的几个动作,做完就能覆盖绝大部分场景。
需要说明的是,这三层不是互相替代的关系,而是纵深。属性层做对了,注入点存在也读不走会话;响应头层做对了,注入点本身就很难形成;检测层做对了,前两层万一有漏,你还能及时发现。只做一层就等于只买了一份保险,出事的时候还是裸奔。
4.1 Set-Cookie 属性怎么组合才不留后门
会话 Cookie 的推荐组合是:HttpOnly+Secure+SameSite=Lax(或Strict)+Path=/+ 不设Domain+ 较短的Max-Age。如果这个 Cookie 是关键凭据,再套一层__Host-前缀,浏览器会强制要求它必须带Secure、必须Path=/、且不能有Domain属性,等于把配置错误直接变成不生效,这比靠人记住规则可靠得多。
// 服务端下发会话 Cookie 的推荐写法(Node/Express 风格伪代码) // 关键点:属性组合一次性配齐,不依赖后续补丁 res.cookie('__Host-sessionid', sessionToken, { httpOnly: true, // 脚本不可读,切断 document.cookie 这条读取路径 secure: true, // 仅 HTTPS 传输,防止明文链路上被截获 sameSite: 'lax', // 跨站 POST 不带,缓解 CSRF;有强需求再评估 strict path: '/', // __Host- 前缀强制要求,同时限制作用域 maxAge: 30 * 60 * 1000 // 30 分钟,把泄露后的可用窗口压到最小 // 注意:这里刻意不设置 domain,让 Cookie 只对当前主机生效 });参数逐个说一下。httpOnly是这条链路上最关键的一个开关,它直接让document.cookie读不到这条记录,代价是前端也读不到——如果你的前端逻辑依赖读取会话值,那说明设计需要改,而不是把这个开关关掉。secure防止在明文 HTTP 请求里带上 Cookie,配合 HSTS 效果更好。sameSite的取值要按业务来:Lax是默认推荐,跨站的 GET 导航会带上,POST 表单不带;Strict更严但会影响从外部链接跳转进来的登录态,需要实测。maxAge是最容易被忽略的一项,很多人设成七天甚至三十天,等于把泄露后的窗口拉到极限。
关于__Host-前缀,有个实操上的坑:一旦加了前缀,Cookie 名就变了,服务端读取的时候必须用带前缀的完整名称,否则会读不到。这个改动会影响所有依赖该 Cookie 名的代码路径,包括网关、鉴权中间件、日志解析规则。所以要么一开始就用,要么改的时候全局搜一遍,别只改了下发的地方。
4.2 CSP 与信任边界收敛
内容安全策略的作用是限制页面能加载和执行哪些脚本,它是在"注入已经发生"的前提下做减法——即使攻击者把恶意内容塞进了页面,浏览器也不会执行它,或者至少不会执行外域脚本。这层防护和 HttpOnly 是互补的:HttpOnly 保护的是凭据,CSP 保护的是执行环境。
// 通过响应头下发 CSP,禁止内联脚本和外域脚本执行 // 生产环境先用 Content-Security-Policy-Report-Only 观察一段时间再切换 const csp = [ "default-src 'self'", // 默认只允许同源资源 "script-src 'self'", // 脚本仅同源,禁掉内联和 eval "object-src 'none'", // 禁掉插件类内容,减少老式攻击面 "base-uri 'none'", // 防止 <base> 标签被劫持改写相对路径 "frame-ancestors 'self'", // 限制被第三方页面嵌套 "report-uri /csp-report" // 违规上报,用来发现遗漏的合法资源 ].join('; '); res.setHeader('Content-Security-Policy', csp);这里最容易翻车的是script-src 'self'这一条。绝大多数存量项目都有内联脚本、行内事件处理器(onclick)或者eval调用,直接上线会大面积白屏。正确的做法是先只下发Content-Security-Policy-Report-Only,把违规上报收集一到两周,看看哪些是必须保留的,再逐个改成外部文件加 nonce 或者 hash 的方式,最后才切到强制执行。这个过程可能要改几十个文件,但没有捷径。
信任边界收敛是另一条线。把主站、后台、营销页拆到不同主域下,而不是挂在同一个父域的子域,这样即使营销页被打穿,会话 Cookie 也不会被共享。第三方脚本尽量放到独立子域并加integrity校验,同时定期审查清单——我见过太多"半年前加的一个埋点脚本还在跑,负责人已经离职"的情况。清单不维护,边界就是虚的。
4.3 检测侧:怎么判断已经发生
前三层都是预防,这一层是兜底。因为总有你没想到的地方,所以需要有能力发现"已经发生"。会话劫持的检测难点在于,攻击者拿到 Cookie 之后的行为和你正常用户几乎一样,唯一可能露出破绽的是环境突变——同一个会话在极短时间内从两个地理位置、两个设备指纹、两个网络出口出现。
我在日志侧一般看三类信号。第一类是同一会话标识在短时间内出现多个不同的客户端指纹(UA、屏幕分辨率、时区、语言这几项组合起来)。第二类是 Cookie 值出现在不该出现的地方,比如 URL 查询串、Referer 头、错误日志的堆栈里。第三类是异常的成功登录和敏感操作,尤其是从陌生 IP 段直接访问后台接口而不是先访问首页的情况。
# 日志侧筛查:找出同一会话标识对应多个客户端指纹的异常情况 # 字段假设为:session_id / ip / user_agent / ts awk -F'\t' '{key=$1; ua[key][$3]++} END { for (s in ua) { n = 0 for (u in ua[s]) n++ if (n > 1) print s, n # 一个会话对应多个 UA,值得人工看一下 } }' access.log | head -50这段脚本只是把候选集缩小,不是判定工具。输出里的会话标识需要人工结合时间、IP、操作序列去判断——正常的 UA 变化可能只是浏览器升级或者用户换了设备,而攻击者的特征往往是"UA 完全一致但 IP 跨度极大"或者"操作节奏异常快"。参数上,-F'\t'要换成你日志实际的分隔符,字段位置也要按你的格式调整,不要直接照抄。
除了日志,还有一个成本很低但效果明显的手段:给关键操作加二次验证。比如修改密码、绑定手机、导出数据这类动作,强制要求重新输入密码或者走一次短验证。即使会话被劫持,攻击者也过不了这一关。这个改动对正常用户的打扰有限,但对劫持者的阻断率接近百分之百。
5. 避坑与常见问题:上线前我自己踩过的五个跟头
加固方案写得再漂亮,落地时总会遇到各种意料之外的情况。这一章记的都是我自己在验证和上线阶段真踩过的坑,按"现象 → 原因 → 解决"记下来,省得下次再翻一遍。
5.1 配置层面的三个跟头
现象一:给 Cookie 加了 Secure 之后,本地开发环境登录直接失效,所有请求都变成未登录。原因很直接——本地跑的是 HTTP,浏览器对带Secure的 Cookie 直接拒收,连设置这一步都不生效。解决方法是在配置里按环境区分:生产环境强制开启,本地环境通过环境变量关闭,而不是把这段代码注释掉。注释掉最容易忘,我见过有人注释了半年没恢复。
现象二:加了__Host-前缀之后,网关侧的鉴权全部失效,但直连后端是正常的。原因是网关的配置里按 Cookie 名做路由和透传,写死的是不带前缀的老名字,前缀加上之后匹配不上。解决方法是全局搜一遍 Cookie 名的字符串字面量,把网关、中间件、日志解析、监控埋点全部改一遍,然后再上线。这类改动的风险不在代码本身,而在那些"没人记得在哪里配过"的地方。
现象三:SameSite=Strict上线后,从邮件、合作方页面点进来的链接全部变成了未登录状态。原因是Strict下跨站导航不带 Cookie,用户从外部跳进来时服务端认为他是匿名的。解决方法是退回Lax,它对顶层 GET 导航是放行的,同时仍然能挡住跨站 POST。如果业务确实需要Strict,那就得接受这个体验代价,或者做一次跳转中继。
5.2 验证与上线阶段的两个跟头
现象四:加固后复测显示"不可读",但过了一周又被查出可读。查下来发现是新上线的一个活动页用了另一套下发逻辑,走的是老代码分支。原因是团队里有多套 Cookie 下发入口,改的时候只改了主站那一处。解决方法是把所有Set-Cookie的产生位置列出来做成清单,纳入发布检查项,每次上线前对一遍。这件事没有技术捷径,只能靠流程。
现象五:CSP 切到强制执行后,页面上大量功能静默失效,控制台报错但用户看不到。原因是之前Report-Only阶段收集的违规日志没人看,直接切了强制模式,把还在用的内联脚本全挡了。解决方法是把上报日志接入日常监控,设一个阈值告警,观察期至少两周,确认违规量降到可控范围再切换。这一步急不得,急了就是全站故障。
这五个坑里,前三个是配置细节,后两个是流程问题。我的体会是,技术方案本身不难,难的是保证"所有入口都改到了"和"改完真的验证过"。后面这一点,只能靠清单和复测,没有别的办法。
6. 该不该碰这类工具:三条判断线和我后来固定的习惯
回到最开始那个问题:cookie-stealer 这类工具到底该怎么用。我给自己划了三条线。第一条是目标线——只在自己拥有书面授权或者完全自建的环境里跑,任何第三方目标一律不碰,这条没有灰色地带。第二条是目的线——目的是验证防御是否有效,不是收集凭据,所以验证脚本里不该有外发逻辑,也不该保留任何读到的值。第三条是留存线——测试环境的凭据、日志、截图在验证结束后立即清理,不给二次泄露留机会。
这三条线看起来是约束,实际是保护。我见过有人为了"证明漏洞真实存在",把读到的会话贴进报告里,结果报告在群里转了几手,那个会话一直没失效——本来是一次内部自查,最后变成了一次真实泄露。这种事出了之后没有后悔药。
具体到操作上,我现在固定走这么一套流程:先列出所有 Cookie 下发入口,逐个核对属性;再用最小验证片段在自建环境跑一遍,记录基线表;然后按"属性 → 响应头 → 检测"三层加固;加固完再跑一遍同样的验证,对比前后差异;最后把入口清单和基线表归档,下次改动时直接拿来对照。整个流程走完大概半天,比事后排查一次事件的成本低得多。
有一个细节值得单独说:验证的时候不要只看"能不能读到",还要看"读不到是不是因为真的设了 HttpOnly"。这两种情况在控制台里表现是一样的——都是空。区分办法是去开发者工具的 Cookies 面板看那一列有没有勾选,或者用服务端的响应头直接确认。我早期就因为只看控制台输出,误判过一次,以为修好了,实际上只是那个页面当时没有下发 Cookie 而已。
从那以后我每次做这类验证,都强制走一遍"看响应头 → 看 Cookies 面板 → 跑验证片段"三步,三步结果对得上才算数。单看任何一步都可能被骗,尤其是单看控制台输出。这个习惯看着笨,但它是唯一能让我在复盘时说得清"当时到底验证了什么"的办法。希望帮到你。
本文还有配套的精品资源,点击获取