mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
4624 字
12 分钟
网页防复制的七层封印和七把钥匙
2026-09-02

在付费内容平台、在线考试系统、小说站上,你大概都遇到过:选不中文字、右键没反应、复制出来是乱码。这些手段从最简单的一行 CSS 到最变态的字体混淆,技术含量跨了好几个量级,但底层逻辑只有一条:浏览器是客户端,用户对渲染结果有完全控制权。所有防复制方案都是在拉高复制成本,不存在无法绕过的方案。

这篇按防御强度从低到高,拆解七种主流手段的实现原理,每种附上对应的绕过方法。读完你会清楚:站在防御方该怎么组合,站在使用方该怎么拆解。

CSS user-select: none:最常见也最脆弱的一层#

最简单的防复制,一行 CSS:

defense.css
body {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

效果是鼠标拖拽选不中任何文字。大量博客站和文档站用的就是这一招。

绕过方法:

方法一,DevTools 直接覆写。打开开发者工具 → Elements 面板 → 选中 body(或对应元素),在 Styles 面板里添加 user-select: auto !important

方法二,注入样式。方法三,浏览器阅读模式(Firefox / Safari 会重新渲染页面内容,user-select 不生效)。

bypass.js
const s = document.createElement('style');
s.textContent = '* { user-select: auto !important; }';
document.head.appendChild(s);

防御强度:极低。 只能挡住不会打开 DevTools 的用户。但胜在零成本、零副作用,作为第一层防线没有坏处。

JS 事件拦截:禁右键、禁选择、禁快捷键#

比 CSS 稍重的一层,用 JavaScript 拦截所有和复制相关的事件:

defense.js
// 禁止右键菜单
document.addEventListener('contextmenu', e => e.preventDefault());
// 禁止选择
document.addEventListener('selectstart', e => e.preventDefault());
// 禁止复制
document.addEventListener('copy', e => e.preventDefault());
// 禁止 Ctrl+C / Ctrl+A 等快捷键
document.addEventListener('keydown', e => {
if (e.ctrlKey && ['c', 'a', 'u', 's'].includes(e.key.toLowerCase())) {
e.preventDefault();
}
});

四件套一起上,右键、选择、Ctrl+C、Ctrl+A、Ctrl+U(查看源码)、Ctrl+S(保存页面)全部失效。

绕过方法:

方法一,禁用 JavaScript。浏览器设置里关掉当前站的 JS,所有事件监听都不存在了。Chrome 可以在 DevTools → Sources 面板底部点齿轮图标 → 勾选 “Disable JavaScript”。

方法二,用 DevTools 查看和移除事件监听器:

DevTools 控制台
// Chrome DevTools 专属 API,查看 document 上所有监听器
getEventListeners(document);
// 输出:{contextmenu: Array(1), selectstart: Array(1), copy: Array(1), ...}

替换后页面样式不变,但所有通过 addEventListener 绑定的事件全部消失。

方法三,用浏览器扩展。“Allow Copy” 类扩展的原理也是上面这些:注入脚本覆写 user-select,移除事件拦截。

防御强度:低。 JS 跑在客户端,用户有最终控制权。但对普通用户来说,这一层配合 CSS 已经够用了。

剪贴板劫持:复制到的不是你选的内容#

这一层不阻止你复制,而是在你复制的时候偷换内容:

defense-append.js
document.addEventListener('copy', e => {
e.preventDefault();
const selection = window.getSelection().toString();
// 在复制的内容后面追加版权信息
const modified = selection + '\n\n——来源:example.com,转载请注明出处';
e.clipboardData.setData('text/plain', modified);
});

更过分的做法是直接替换成广告、乱码,甚至把你选中的文字改成完全不同的内容。知乎早期就用追加来源的方式。

绕过方法:

跳过剪贴板,直接从 DOM 取文本,不经过 copy 事件:

bypass-selection.js
// 先在页面上选中文字,再在控制台执行
window.getSelection().toString();

防御强度:低。 本质上只是在 copy 事件里做文章,绕过 copy 事件就完全失效。

透明遮罩层:看得见选不中#

在内容区上面盖一个全透明的 div,占满整个内容区域:

defense.html
<div style="position: relative;">
<div class="content">
这里是要保护的内容...
</div>
<!-- 透明遮罩:拦截所有鼠标事件 -->
<div style="
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
z-index: 9999;
background: transparent;
"></div>
</div>

鼠标点击和拖拽事件都被遮罩层吃掉了,底下的文字选不中。部分在线阅读器和文档预览系统用的就是这个思路。

绕过方法:

bypass.css
/* DevTools Elements 面板找到遮罩 div,加一条 */
pointer-events: none;

也可以直接从 DOM 取 textContent,和上一节一样。

防御强度:低。 遮罩层在 DOM 里清晰可见,删掉就没了。但对不看 DevTools 的用户来说是有效的视觉欺骗。

文字转图片 / Canvas 渲染:DOM 里根本没有文本#

前面四种方法有一个共同弱点:文字始终以文本形式存在于 DOM 中,用 textContent 就能取到。把文字渲染成图片可以从根本上消除这个入口。

服务端渲染成图片: 后端把文章内容渲染成一张张图片,前端用 <img> 展示。部分在线文档预览(Google Docs 的只读分享页、某些 PDF 在线查看器)就是这个思路。

前端 Canvas 绘制: 把文字画到 <canvas> 上,用户看到的是像素而不是文本节点:

defense-canvas.js
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
const ctx = canvas.getContext('2d');
ctx.font = '16px sans-serif';
ctx.fillStyle = '#333';
const text = '这段文字无法通过 DOM 获取';
ctx.fillText(text, 20, 40);
document.body.appendChild(canvas);

Canvas 里的内容在 DOM 中只是一个 <canvas> 元素,textContent 返回空字符串。

绕过方法:

OCR(光学字符识别)。浏览器环境下可以直接用 Tesseract.js:

bypass-ocr.js
// npm install tesseract.js
import { createWorker } from 'tesseract.js';
async function ocrFromCanvas(canvas) {
const worker = await createWorker('chi_sim'); // 中文简体
const { data: { text } } = await worker.recognize(canvas);
await worker.terminate();
return text;
}
// 也可以先把 canvas 转成图片 URL
const dataUrl = canvas.toDataURL('image/png');

对于服务端渲染的图片,直接对 <img> 元素做 OCR,或者截图后用系统 OCR(macOS 的实况文本、Windows 的 PowerToys Text Extractor)。

防御强度:中。 第一次出现了不在 DOM 里直接可取的情况。但 OCR 技术成熟,中文识别准确率已经很高(Tesseract 对规范字体的识别率 > 95%)。代价是:图片体积远大于文本、不可被搜索引擎索引、无法被屏幕阅读器读取、不支持响应式排版。

无障碍问题

文字转图片会让使用屏幕阅读器的用户完全无法获取内容。如果你的站点需要满足 WCAG 标准,这个方案不可接受。即便加了 alt 文本,长篇内容的阅读体验也远不如真实文本。

DOM 碎片化 + CSS 视觉重排:复制出来是乱序#

这一招比较巧妙:文字以文本形式存在于 DOM 中,但 DOM 顺序和视觉顺序不一致。

假设要展示「前端安全」四个字,DOM 结构可能是:

defense.html
<div class="protected">
<span style="order: 3">安</span>
<span style="order: 1">前</span>
<span style="order: 4">全</span>
<span style="order: 2">端</span>
</div>
<style>
.protected { display: flex; }
</style>

视觉上看到的是「前端安全」(flex order 控制排列顺序),但 textContent 取到的是 DOM 顺序:「安前全端」。

更激进的做法是用绝对定位把每个字符放到精确的像素坐标上,DOM 顺序完全随机打乱。有的实现甚至会插入不可见的干扰字符(font-size: 0color: transparent),复制出来夹杂一堆乱码。

绕过方法:

核心思路:按视觉坐标还原真实顺序。

bypass.js
function extractByVisualOrder(container) {
const spans = [...container.querySelectorAll('span')];
// 按视觉位置排序:先按 top(行),再按 left(列)
spans.sort((a, b) => {
const ra = a.getBoundingClientRect();
const rb = b.getBoundingClientRect();
if (Math.abs(ra.top - rb.top) > 5) return ra.top - rb.top; // 不同行
return ra.left - rb.left; // 同一行
});
return spans.map(s => s.textContent).join('');
}
const result = extractByVisualOrder(document.querySelector('.protected'));
console.log(result); // "前端安全"

getBoundingClientRect() 返回的是元素在屏幕上的实际渲染位置,和 DOM 顺序、CSS 排列方式无关。按坐标排序就能还原视觉顺序。

对于插入的不可见干扰字符,额外过滤一下:

bypass-filter.js
spans.filter(s => {
const style = getComputedStyle(s);
return style.fontSize !== '0px'
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.opacity !== '0';
});

防御强度:中高。 需要写针对性的脚本才能提取,简单的 textContent 拿到的是乱序内容。但只要文字还在 DOM 里,就一定能通过坐标还原。

字体混淆:看到的字和底层编码不同#

这是目前防御强度最高的前端防复制手段。

原理:制作一个自定义字体文件(@font-face),把 Unicode 码位重新映射。比如字体里把 U+5B89(安)的位置画成「前」的字形,把 U+524D(前)的位置画成「端」的字形……HTML 里写的是一串看起来无意义的字符,但加载自定义字体后渲染出来是正确的文章。

defense.css
@font-face {
font-family: 'Obfuscated';
src: url('/fonts/obfuscated.woff2') format('woff2');
}
.protected {
font-family: 'Obfuscated', sans-serif;
}
defense.html
<p class="protected">安前全端</p>
<!-- 视觉上显示的是完全不同的内容 -->
<!-- 复制粘贴到其他地方,因为没有这个字体,显示的是乱码 -->

起点中文网、某些付费文档平台用的就是这种方案(或其变体)。每次请求生成不同的映射表 + 不同的字体文件,连缓存都没法复用。

怎么确认一个站用了字体混淆

选中文字复制粘贴到记事本里。如果粘贴出来的内容和你看到的完全不同(乱码或不同字符),基本可以确认是字体混淆。另一个信号:查看 DevTools → Network 面板,如果有自定义字体文件(.woff2 / .woff)在加载,而且字体名不是常见的系统字体,嫌疑很大。

绕过方法:

需要逆向字体文件的映射关系。用 opentype.js 解析字体:

bypass-font.js
// npm install opentype.js
import opentype from 'opentype.js';
async function reverseFontMapping(fontUrl) {
const response = await fetch(fontUrl);
const buffer = await response.arrayBuffer();
const font = opentype.parse(buffer);
const mapping = {};
// 遍历字体的 cmap 表,建立码位 → 字形的反向映射
const glyphs = font.glyphs;
for (let i = 0; i < glyphs.length; i++) {
const glyph = glyphs.get(i);
if (glyph.unicode) {
// glyph.path 可以和标准字体做轮廓比对
// 实际场景需要更复杂的字形匹配算法
mapping[glyph.unicode] = glyph;
}
}
return mapping;
}

实际操作比这复杂得多:需要把混淆字体里每个字形和标准字体做视觉比对(字形轮廓相似度),才能还原出真实映射。自动化工具存在,但不是开箱即用。

另一条路是截图 + OCR,和上一节一样。字体混淆再怎么混淆,屏幕上显示的像素是正确的。

防御强度:高。 DOM 里的文本和真实内容完全脱钩,复制出来是乱码,连 textContent 取到的都是错的。逆向字体需要专业知识,OCR 虽然通用但处理大量文本的成本不低。缺点是字体文件体积大(中文字体动辄几 MB)、每次请求生成新字体对服务端也有压力。

七种手段的防御纵深#

graph TD U[用户想复制内容] --> L1{CSS user-select} L1 -->|被拦截| L2{JS 事件拦截} L1 -->|绕过| R1[DevTools / 阅读模式] L2 -->|被拦截| L3{剪贴板劫持} L2 -->|绕过| R2[禁用 JS / 移除监听器] L3 -->|被拦截| L4{透明遮罩} L3 -->|绕过| R3[DOM 直取 textContent] L4 -->|被拦截| L5{文字转图片} L4 -->|绕过| R4[pointer-events: none] L5 -->|被拦截| L6{DOM 碎片化} L5 -->|绕过| R5[OCR] L6 -->|被拦截| L7{字体混淆} L6 -->|绕过| R6[坐标排序提取] L7 -->|绕过| R7[逆向字体映射 / OCR]

每一层都拦不住有耐心的攻击者,但每多一层就筛掉一批不愿意付出对应成本的人。

网页是怎么加载的:扩展工作在哪一层#

前面七种手段看起来是七个孤立的技巧,但把它们放回网页加载的完整流程里,每个手段卡住的位置不同,扩展能赢前三层的原因也很清晰。

从 URL 到像素:加载管线的五个阶段#

第一阶段:网络请求。 DNS 解析域名 → TCP/TLS 握手 → 发送 HTTP 请求 → 服务器返回 HTML。

第二阶段:HTML 解析。 浏览器逐字节解析 HTML,从上往下构建 DOM 树。遇到 <script> 标签时默认暂停解析,先下载并执行这段 JS,然后才继续。这就是为什么页面脚本的执行时机和它在 HTML 里的位置有关——写在哪里,就在那个位置执行。

第三阶段:样式计算。 解析 CSS(包括 @font-face 触发的字体文件下载),构建 CSSOM,和 DOM 合并成渲染树。user-select: none 在这一步生效。

第四阶段:布局与绘制。 计算每个节点的几何位置(layout),栅格化成像素(paint),合成到屏幕(composite)。透明遮罩和字体混淆的视觉效果在这一步呈现。

第五阶段:交互循环。 页面就绪,进入事件循环等待用户输入。每次点击、拖拽选择、Ctrl+C 都走同一个事件分发路径:从 window 开始捕获(capture)向下到目标元素,再从目标元素冒泡(bubble)回到 window。JS 事件拦截和剪贴板劫持就是在这一步做文章。

七种手段各自卡在哪一步#

手段卡住的阶段具体位置
CSS user-select第三阶段:样式计算CSSOM 中的选择属性
JS 事件拦截第五阶段:交互循环事件分发路径上的监听器
剪贴板劫持第五阶段:交互循环copy 事件的 handler
透明遮罩第四阶段(呈现)+ 第五阶段(拦截)层叠上下文中盖在内容上方
文字转图片第二阶段:HTML 解析DOM 里根本没有文本节点
DOM 碎片化第二阶段:HTML 解析DOM 顺序与视觉顺序不一致
字体混淆第三阶段:样式计算@font-face 的码位映射

扩展卡在哪:两个切入点#

浏览器的 content script 机制给了扩展两个关键切入点:

graph LR subgraph 网页加载管线 A["1 网络请求<br/>DNS → TCP → HTTP"] --> B["2 HTML 解析<br/>构建 DOM 树"] B --> C["3 样式计算<br/>CSSOM + 字体"] C --> D["4 布局绘制<br/>layout → paint"] D --> E["5 交互循环<br/>事件捕获 → 目标 → 冒泡"] end X["扩展 content script<br/>document_start 注入<br/>早于页面脚本执行"] -.->|切入点 1| B Y["样式注入<br/>user-select: auto !important<br/>MutationObserver 自动重注入"] -.->|切入点 2| C Z["事件拦截<br/>window 捕获阶段<br/>stopImmediatePropagation"] -.->|切入点 3| E

切入点 1:解析阶段的最早期(document_start)。 content script 在 DOM 树刚创建、页面自己的 <script> 还没执行时注入。这保证了扩展的事件监听器先于页面注册——抢占的是时间上的先手。

切入点 2:样式计算阶段。 注入 user-select: auto !important 直接覆盖页面的 user-select: noneMutationObserver 监视样式节点,页面试图删掉时自动重新注入——覆盖的是渲染规则。

切入点 3:事件分发的最顶端(window 捕获阶段)。 所有事件到页面元素之前必经 window 的捕获阶段。在这里调 stopImmediatePropagation(),事件就不会到达页面注册的任何监听器——抢占的是传播路径上的先手。

隔离世界(Isolated World)

Content script 和页面脚本共享同一个 DOM,但运行在独立的 JavaScript 上下文里。页面访问不到扩展的变量和函数,也无法通过 removeEventListener 移除扩展的监听器——它根本拿不到引用。这就是为什么页面脚本对扩展几乎没有反制手段。

前三层防复制手段全部失效的根本原因:它们和扩展在同一个战场上作战,但扩展在时间(注入更早)、传播路径(捕获更靠前)、上下文(隔离世界不可见)三个维度上都卡在了页面代码够不着的位置。

后四层手段扩展处理不了,原因也可以从这张图上读出来:透明遮罩是 DOM 结构问题(形态太多,无法通用识别),图片和字体是内容呈现层问题(DOM 里就没有可取的文本,或者取到的是乱码),DOM 碎片化是结构语义问题(需要理解视觉排序)。这些层面的问题,注入时机再早也没用。

一个实际实现:Enable Copy 浏览器扩展#

前面七节的绕过方法大多要开 DevTools 手动操作,每次遇到防复制页面都来一遍显然不现实。我把前三层的绕过逻辑打包成了一个浏览器扩展:

L-1ngg
/
enable-copy-extension
Waiting for api.github.com...
00K
0K
0K
Waiting...

它对应处理文章里的前三层:

文章中的手段扩展的对应实现
CSS user-select: none注入 user-select: auto !important,并用 MutationObserver 监听——页面把样式节点删掉时自动重新注入
JS 事件拦截内容脚本在 document_start 阶段注入,早于页面自己的脚本;在 window 捕获阶段对 contextmenu / selectstart / copy / cutstopImmediatePropagation(),事件根本到不了页面的监听器
剪贴板劫持同上,copy / cut 事件在捕获阶段被放行,劫持代码不会执行

有个实现细节值得说一下:为什么用捕获阶段的 stopImmediatePropagation() 而不是简单地 removeEventListener?因为扩展拿不到页面脚本注册监听器时的函数引用,没法精确移除。但事件传播有顺序——捕获阶段先于冒泡阶段,window 又处于捕获链的最顶端。在 window 上注册捕获监听器,调 stopImmediatePropagation() 阻止事件继续传播,页面的监听器就收不到事件了。不需要知道页面绑了什么,直接在传播路径上截断。

document_start 的时机选择也关键:内容脚本在页面任何脚本执行之前注入,保证扩展的监听器先于页面注册。如果晚于页面脚本,页面注册在前的监听器会先收到事件,拦截就失效了。

第四层(透明遮罩)没有覆盖——遮罩层形态太多样,通用识别成本高,实际遇到的频率也低。第五层(图片/Canvas)和第七层(字体混淆)在原理上就无法通过 DOM 操作解决,README 里也明确写了这两个限制,需要配合 OCR。

仓库里附了一个 test-page.html,模拟了禁选择、禁右键、复制劫持、禁快捷键、CSS 限制五种场景,装完扩展可以直接验证效果。

实战中怎么组合:成本取舍#

没有单一手段能做到绝对防复制,但多层组合可以把成本拉到大部分人不愿意付的程度。

手段防御强度实现成本用户体验影响无障碍影响
CSS user-select极低几乎为零
JS 事件拦截轻微(右键失效)轻微
剪贴板劫持中(复制内容被篡改)
透明遮罩中(可能影响交互)
文字转图片高(不可搜索、不可缩放)严重
DOM 碎片化中高低(视觉正常)严重
字体混淆很高低(视觉正常)严重

普通博客 / 内容站: CSS user-select + JS 事件拦截 + 剪贴板追加来源,三层叠加已经能挡住 95% 的随手复制。实现成本低,用户体验影响可控。

付费内容平台: 在上面的基础上加 DOM 碎片化或字体混淆。两者都能保持视觉正常,但复制出来是乱码。字体混淆更强但成本更高,需要服务端配合生成字体。

在线考试 / 法律文书: 文字转图片 + Canvas 渲染是最常见的方案。配合禁用 DevTools 的检测(检测窗口尺寸变化、debugger 断点),进一步提高提取难度。但要注意无障碍合规问题。

底线

不管你选哪种方案,别忘了用户体验。禁右键会影响翻译插件和辅助功能,文字转图片会让屏幕阅读器用户完全失去内容,字体混淆会导致浏览器内搜索失效。防复制的每一层都在消耗用户体验预算——加到什么程度,取决于你的内容值多少钱。

如果用户能看到,用户就能复制。区别只在于成本。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

网页防复制的七层封印和七把钥匙
https://l1ngg.info/posts/tech/web-copy-protection/
作者
L1ngg
发布于
2026-09-02
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录