用油猴脚本自动检测网页水印

在 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.更新代码,使控制台输出所有疑似水印元素,而不只是第一个。

全部为采集文章,文中的 联系方式 均不是 本人 的!

发表评论