在 V2EX 看到帖子,知乎在网页等全端加上隐写水印,水印信息包括用户 ID 及时间戳;肉眼很难察觉,几乎只能通过专业分析还原。截至本文发布,知乎似乎已经下线水印。帖子下面一些回复很有价值,网友给出各种分析与应对方式:在网页端,包括用 uBlock Origin 等插件以去广告方式去除,提醒网友对截图进行二值化处理等等。其中一个方法是用油猴脚本检测。
本文假定你已经了解 HTML、CSS、JavaScript 以及油猴脚本。油猴脚本是用于 GreaseMonkey 等浏览器扩展组件的脚本,本质是用户附加在网页上的一段 JavaScript 代码。用油猴脚本检测,即是用 JavaScript 检测。
根据 #7 @ZhiyuanLin 给出的信息,知乎这次的隐写水印承载于 HTML 网页上的 div 元素,以 svg 图片作为背景:
.css-xxxxxx {
position: fixed;
top: 0px;
width: 100%;
height: 100%;
background: url("data:image/svg+xml;base64,此处是 base64 编码的 svg 图片,已移除") space;
pointer-events: none;
}
svg 不同于一般理解的图片,它不是位图,而是一系列规则生成的矢量图片(也可以引用包含位图)。非常容易生成给定文本的 svg 图片,作为水印使用也就不难理解了。#90 @coolzjy 写了一个油猴脚本,可以检测并提示类似的水印:
// ==UserScript==
// @name Detect Watermark
// @version 0.3
// @description Detect invisible watermark on the page to avoid track
// @author You
// @match https://*/*
// @grant none
// @run-at document-idle
// @namespace https://greasyfork.org/users/474693
// ==/UserScript==
(function () {
'use strict';
function isWatermarkElement(el) {
const style = getComputedStyle(el);
return (style.pointerEvents === "none" &&
style.position === "fixed" &&
style.backgroundImage.toLowerCase().includes("data:"));
}
const LANG = {
"zh-CN": {
warn: "⚠️ 当前页面可能含有水印,请注意保护个人信息!",
dismiss: "知道了",
},
};
function getText(key) {
const text = LANG[navigator.language] ?? LANG["zh-CN"];
return text[key];
}
async function detect() {
return new Promise((resolve) => {
const elements = document.querySelectorAll("*");
let cursor = 0;
const run = ({ didTimeout }) => {
for (; cursor < elements.length; cursor++) {
const element = elements[cursor];
if (isWatermarkElement(element)) {
resolve(element);
return;
}
if (didTimeout) {
requestIdleCallback(run);
return;
}
}
resolve();
};
requestIdleCallback(run);
});
}
function report(el) {
const shadowHost = document.createElement("div");
const shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
document.body.appendChild(shadowHost);
const notice = document.createElement('div');
notice.setAttribute("style", [
"position: fixed",
"z-index: 99999",
"top: 10px",
"right: 10px",
"left: 10px",
"display: flex",
"justify-content: space-between",
"align-items: center",
"color: white",
"background: red",
"border-radius: 8px",
"padding: 8px",
].join(";"));
notice.innerText = getText("warn");
const button = document.createElement("button");
button.innerText = getText("dismiss");
button.addEventListener("click", () => {
document.body.removeChild(shadowHost);
});
notice.appendChild(button);
shadowRoot.appendChild(notice);
}
setTimeout(async () => {
const watermarkEl = await detect();
if (watermarkEl == null)
return;
report();
}, 5000);
})();
实测有效。
不过要说“类似”,有点过于“类似”了。查看 isWatermarkElement(el)
方法,水印元素 el 的位置一定是固定的吗(position:fixed
)?不见得。所以可以去除这个条件。水印一定是使用 background 的元素吗?<svg>
也可以是独立的标签元素,也允许使用 <embed>
、<object>
、<iframe>
标签嵌入 svg。另外,可以使用 canvas 绘制水印,于是我的修改版如下:
// ==UserScript==
// @name Detect Watermark
// @version 0.3
// @description Detect invisible watermark on the page to avoid track (mod by Shansing)
// @author You
// @match http://*/*
// @match https://*/*
// @grant none
// @run-at document-idle
// @namespace https://greasyfork.org/users/474693
// ==/UserScript==
(function () {
'use strict';
function isWatermarkElement(el) {
const style = getComputedStyle(el);
return (style.pointerEvents === "none" &&
style.backgroundImage.toLowerCase().includes("data:") //&&
//style.backgroundImage.toLowerCase().includes("image/svg")
)
||
(style.pointerEvents === "none" &&
el.tagName.toLowerCase() === "canvas")
||
(style.pointerEvents === "none" &&
el.tagName.toLowerCase() === "svg")
||
(style.pointerEvents === "none" &&
el.tagName.toLowerCase() === "embed")
||
(style.pointerEvents === "none" &&
el.tagName.toLowerCase() === "object")
||
(style.pointerEvents === "none" &&
el.tagName.toLowerCase() === "iframe")
;
}
const LANG = {
"zh-CN": {
warn: "⚠️ 当前页面可能含有水印,请注意保护个人信息!",
dismiss: "知道了",
},
};
function getText(key) {
const text = LANG[navigator.language] ?? LANG["zh-CN"];
return text[key];
}
async function detect() {
return new Promise((resolve) => {
const elements = document.querySelectorAll("*");
let cursor = 0;
const run = ({ didTimeout }) => {
let foundElement = null;
for (; cursor < elements.length; cursor++) {
const element = elements[cursor];
if (isWatermarkElement(element)) {
console.log("Detect Watermark", element);
//resolve(element);
//return;
foundElement = element;
}
if (didTimeout) {
requestIdleCallback(run);
return;
}
}
resolve(foundElement);
};
requestIdleCallback(run);
});
}
function report(el) {
const shadowHost = document.createElement("div");
const shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
document.body.appendChild(shadowHost);
const notice = document.createElement('div');
notice.setAttribute("style", [
"position: fixed",
"z-index: 99999",
"top: 10px",
"right: 10px",
"left: 10px",
"display: flex",
"justify-content: space-between",
"align-items: center",
"color: blank",
"background: #ccc",
"border-radius: 8px",
"padding: 8px",
].join(";"));
notice.innerText = getText("warn");
const button = document.createElement("button");
button.innerText = getText("dismiss");
button.addEventListener("click", () => {
document.body.removeChild(shadowHost);
});
notice.appendChild(button);
shadowRoot.appendChild(notice);
}
setTimeout(async () => {
const watermarkEl = await detect();
if (watermarkEl == null)
return;
report();
}, 6000);
})();
你可能注意到 isWatermarkElement(el)
,我在第一个条件中要求背景图片的地址不仅包含 data:
,还要包含 image/svg
,然后我又注释掉了。这是因为我想有些网页元素确实只是想显示用 base64 编码的 png 图片(data:image/png
),而不旨在 svg 打水印。你会想,普通图片不能是生成的水印吗?完全可能,所以我最后没有应用该条件,不过理论上这时也要提防普通的 <img>
元素。我就没有一步到位考虑周全了,毕竟猫捉老鼠相互促成。比如,你现在认为 pointer-events:none
是水印必要样式,我如果是打水印的网站,知道你的想法,难道不能用别的值吗?更进一步,水印一定是图片吗?这些就留给各位自己思考,本文算作抛砖引玉。
除了完备性外,当然也有正确性的问题,也就是误报。所以我加上一行 console.log("Detect Watermark", element)
,如果看到提示说检测到水印,可以打开控制台排查详情。
2022-09-05 P.S.更新代码,使控制台输出所有疑似水印元素,而不只是第一个。