本来以为水源只是做了一个简单的 LSB 隐写,顺手撸了一个去除脚本。没想到前两天使用脚本时,水源居然自动刷新了!而且任何 Event Listener Breakpoint 都无法检测到这个回调函数,任由其刷新。今天考完军理,我倒要看看水源用了什么黑魔法。

声明:本人只是出于理论研究的目的对源代码进行研究并提出可能的应对方案,请任何人都不要把水源截图外传,更不要利用本文提到的方式逃避管理。在校内群,请使用链接分享帖子。至于为什么,请自行水源上搜索 截图外传(ps 一个我很喜欢的解释)。

水源社区管理规范

第十一条 以下行为被界定为“恶意对抗行为”:

(一)使用技术手段逃避管理;

第十二条 以下行为被界定为“对社区的恶意行为”:

(二)未经授权许可,将他人发布的内容以图片、视频等形式发布到社区以外,并造成不良影响;

找到源代码

通过简单摸索我们会发现,水印被放在一个 .ember-view 内部,通过一个 z-index=9999opacity=0.005 的全屏的 base64 化的 PNG 写入了可能唯一标识了个人账号的三位 base64(大概能表示 $64^3=262,144$ 人,也就是只有二十年的交大人吧/doge)。

由于断点没有任何作用,我只好把一个网页的全部代码下载下来:

全部文件

$ ls
 05d79569ae40510aab461b2563db6b47d00e8cd4.js
 0aa74b582acb6a91dd3e411b2a7bfbc04e260211.js
 108af40fa662a89aaf7a21f877353d51e03f20ab.js
 11312c7a2877d0b500b0e6a8c206a7dddd248d68.js
 13256e7c9a732d6f122bd0ade6ce1667f1056fc8.js
 19b4640f9b7fefd688672e06cba5d97f064376b1.js
 2953c571d30c38ce62223ab7cb71bfa62ff7f6b3.js
 2e3da41114c0548f77ba38b143d059f6e71b6070.js
 3cd5da1afde47c3b2559a9d32fb22dae709bc452.js
 5a71c44fba2bc8a8ba3db3aaed76bbf2edc6ef2b.js
 609808483b2f30b853cf8365c6210db98a45e7bf.js
 755071_2.png
 819585_2.jpg
 9c11c73a9c8a1d753cb2c89ddf7215f2fcbb7d79.js
 a1df1e94bf4b5b5825e5947ad9acccb47fdaaa08.js
 abe5e87ff37d7d06fc18f6e696f906528dfc9da8.js
 ba83de1a244eff6ea32b9cfdb456fc943d55fba2.js
 browser-detect-13a181da51a93c36e477dd3626795ba449290653fdff8a.js
 browser-update-a7811824a1beeecce86fb3546ab789b1788261ece30a33.js
 c0eff5115343eeb12e312488f9572bf683b25619.js
 c36051143379f82fc44d6b2b723f499d097138a6.js
 cdb089134551f0d890ff37adaac1b7fa96a5ba1f.js
 chat-3c2c0a87bea84663ed8fd3479fd008569160424dff2e5904463c1aea.js
 chat_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 chat_desktop_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 checklist-f67957fc54745e2d28d433f45b8fdddd34d8930865fd51c82a0.js
 checklist_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 chunk.5ef2f01c75e63a9864e3.js
 chunk.ef28e73e3d8ce45a79d7.js
 color_definitions_scheme_1_3_2ceb21439b63ac43c0d23727e715500.css
 color_definitions_scheme_2_3_e0c576c10b96945224a8cfc1c5995b7.css
 continous-days-visited-0a1f3a5a289feed212e3b738f8280656ac5cee.js
 d9439395e9e8be8688cc93b588dd8ecf1115a1f8.js
 db287b65037a3ce2491b9ad5245c877b323ff6a8.js
 db38b836d86e35614ac30164555cd84c3bc3c6dd.js
 desktop_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 desktop_theme_37_1522da10433779d746cafc79ea7feff6ebe9d770.css
 desktop_theme_3_3d24bc909a4159746945cecfa8cee8de96493824.css
 desktop_theme_56_353dbf58327f2e62005109874adbb04510666e04.css
 desktop_theme_58_c6d6de17779690824e99a36648e4df96ab67615f.css
 desktop_theme_59_1c846f489c78ccaf6afbd8bb7b7973150ec09bd1.css
 desktop_theme_64_96b396354093d5bcb7d0dab382193683be0e77e8.css
 desktop_theme_65_1cb59dc2d246bcf71678aefca67c06c27cfb7599.css
 desktop_theme_68_8400573319944af0aa635a2f7f7e5d42c3b7fd6c.css
 desktop_theme_74_677da7fcf51b0143ed98730f3aba364459b74f70.css
 desktop_theme_79_a1825946d3fe7d97d214f84d21f15cc07b0884c3.css
 desktop_theme_81_79a7a6e8301d472501b52ee8864518bd570def04.css
 desktop_theme_83_85aa855d093c947c912fee33b90d3f77d331f292.css
 desktop_theme_89_2d9dce6b7b598acbb798bcb9a4cbc8a4769c2d40.css
 desktop_theme_91_259e9839336f626e95899ea3689c6383cce9f638.css
 discourse-automation-797d429c98a553f481d0c33e150e5afe8261a1f4.js
 discourse-automation_b2c67b8084a86f0477766fc1e3b4cc189a786e9.css
 discourse-bbcode-3079b7785a470c5508894ce7e21ef4f760f675fb88ea.js
 discourse-bbcode_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-cakeday-c97b9d03e7a391b25a24f008ca0ffebfd402e9bb3b6.js
 discourse-cakeday_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-data-explorer-1248d71f16c4796d46d30ee2597df96b716ad.js
 discourse-data-explorer_b2c67b8084a86f0477766fc1e3b4cc189a78.css
 discourse-details-26aae552e6a41eaae05c07286af29e9d2f47fecac68.js
 discourse-details_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-docs-d98420e41668b811fce6ebd0cf558a1b271a6e9f13a027.js
 discourse-docs_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-graphviz-f5d1a9b315dbf288505004c1393ac993f5f053ea34.js
 discourse-graphviz_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-lazy-videos-96992b2d9c70100602c892be1e0cdf0f3e0be9f.js
 discourse-lazy-videos_b2c67b8084a86f0477766fc1e3b4cc189a786e.css
 discourse-local-dates-453b2b53c09cec847757bb2efbaf4ab6162ecd6.js
 discourse-local-dates_b2c67b8084a86f0477766fc1e3b4cc189a786e.css
 discourse-math-0b9adce705e8554ecc8d6c4ddbf8713c5bb5a589e07a91.js
 discourse-math_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-narrative-bot-643f10339e287d380f81413fde738a8ad3a6e.js
 discourse-narrative-bot_b2c67b8084a86f0477766fc1e3b4cc189a78.css
 discourse-no-bump-561edd77c2c9cfc1281128910880083ac1c91780c09.js
 discourse-onebox-weibo_b2c67b8084a86f0477766fc1e3b4cc189a786.css
 discourse-presence-eabb23f556643767cf848218ef5a093b1c99496fc6.js
 discourse-presence_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-solved-08c562b8c2c8394867700f33ad2c8286d8a32019553f.js
 discourse-solved_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-templates-f4b7f891267b8392caee8f14278e39ed7e726b8d0.js
 discourse-templates_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 discourse-user-notes-afd4dd7029f812c33e086df9b15444d614dae7a3.js
 discourse-user-notes_b2c67b8084a86f0477766fc1e3b4cc189a786e9.css
 docker_manager_admin-fe9cdca37bd3366d6a0ebb4d5b644d58bdb51647.js
 docker_manager_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 ec422ab66afb234aef01dbfd5b072c1d82cfa5c4.js
 enhanced-ignore-827409206e61dc07ed1362d726142ad57abb3468df0d3.js
 ffc47fcf9607be2ef80b9ec1c4313c3abb1235f2.js
 footnote-e4283bf95e4bdcc5062c1bde5e8962932a35f35298b193181cda.js
 footnote_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 footnote_extra-7f7bb8e0891106317fe6883ba187d239709a385df2c386.js
'logo dark_'$'\346\233\262\347\272\277\345\214\226''.svg'
'logo_'$'\346\233\262\347\272\277\345\214\226''.svg'
 optimized-move-posts-notice-0b54397438579007a4dddc41adc0ae280.js
 overrides
 people_hugging.png
 poll-ff562b4ba36654269cfbdd883265f8f6c938a4f003aa2d8547d5d092.js
 poll_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 poll_desktop_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 retort-e9ced6a1c13cdd0ec63d76b9374daafa59cd75762651e24fe095e3.js
 retort_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 retort_desktop_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 sob.png
 spoiler-alert-7994af0f89dbc4d8cfcda8388fbf06cef011ee2cb622a4d.js
 spoiler-alert_b2c67b8084a86f0477766fc1e3b4cc189a786e96.css
 start-discourse-7c0f5f4daf3c942b7b6ecfe8e62e2e5a0f6555924859c.js
 svg-3-f4d1b64487be6085362dcf71d6570447745763ce.js
 vendor.9e07dd0436aff8751322647cc975361e-0b8308ceb741824f018eb.js
 zh_CN-84e3bf8fa7a50b4444752859a8317a818aa929dc3bc74c45a87c70c.js

可见文件非常非常多。我们以 ember-view 为关键词搜索:

$ grep "ember-view" * -l
11312c7a2877d0b500b0e6a8c206a7dddd248d68.js
chunk.5ef2f01c75e63a9864e3.js
vendor.9e07dd0436aff8751322647cc975361e-0b8308ceb741824f018eb.js

chunk.jsvendor.js 都是常规的打包文件,由于 Discourse 中,自定义的 js 很大概率是通过自定义 Theme 而非编辑 Discourse 源代码实现的(除非维护组愿意每次维护都手动编译源代码),因此真実はいつもひとつ:那个文件就是 11312c7a2877d0b500b0e6a8c206a7dddd248d68.js

因为官方“研究代码实现是没问题的,代码本身没有做任何混淆,欢迎技术交流,只要别讨论屏蔽的方法”的态度,在格式化后源代码勉强能读。令人惊喜的是,js 文件的最后赫然写着

//# sourceMappingURL=11312c7a2877d0b500b0e6a8c206a7dddd248d68.map?__ws=shuiyuan.sjtu.edu.cn*

我们通过 reverse-sourcemap 11312c7a2877d0b500b0e6a8c206a7dddd248d68.map -o source 还原代码,得到以下源代码。

源代码

// source/theme-90/discourse/initializers/theme-field-367-desktop-html-script-1.js
if ("define" in window) {
  define("discourse/theme-90/discourse/initializers/theme-field-367-desktop-html-script-1", [
    "exports",
    "discourse/lib/plugin-api",
  ], function (_exports, _pluginApi) {
    "use strict";

    Object.defineProperty(_exports, "__esModule", {
      value: true,
    });
    _exports.default = void 0;
    const settings =
      require("discourse/lib/theme-settings-store").getObjectForTheme(90);
    const themePrefix = (key) => `theme_translations.90.${key}`;
    var _default = (_exports.default = {
      name: "theme-field-367-desktop-html-script-1",
      after: "inject-objects",
      initialize() {
        (0, _pluginApi.withPluginApi)("0.1", (api) => {
          function e(e) {
            var t = document.createElement("div");
            (t.style.position = "fixed"),
              (t.style.top = "0"),
              (t.style.left = "0"),
              (t.style.width = "100%"),
              (t.style.height = "100%"),
              (t.style.zIndex = "9999"),
              (t.style.pointerEvents = "none"),
              (t.style.backgroundSize = "240px 120px");
            const o = (function (e) {
              const t =
                  "" ==
                  getComputedStyle(document.documentElement).getPropertyValue(
                    "--primary"
                  )
                    ? "#ffffff"
                    : getComputedStyle(
                        document.documentElement
                      ).getPropertyValue("--primary"),
                o =
                  "" ==
                  getComputedStyle(document.documentElement).getPropertyValue(
                    "--secondary"
                  )
                    ? "#000000"
                    : getComputedStyle(
                        document.documentElement
                      ).getPropertyValue("--secondary"),
                n = document.createElement("canvas"),
                r = n.getContext("2d"),
                l = "60px Consolas,Georgia,sans-serif,Arial";
              r.font = l;
              const i = r.measureText(e).width;
              return (
                (n.width = 2 * i),
                (n.height = 120),
                (r.fillStyle = o),
                r.fillRect(i, 0, i, 60),
                r.fillRect(0, 60, i, 60),
                (r.fillStyle = t),
                r.fillRect(0, 0, i, 60),
                r.fillRect(i, 60, i, 60),
                (r.font = l),
                (r.fillStyle = o),
                r.fillText(e, 0, 50),
                r.fillText(e, i, 110),
                (r.font = l),
                (r.fillStyle = t),
                r.fillText(e, 0, 110),
                r.fillText(e, i, 50),
                n.toDataURL()
              );
            })(
              (function (e) {
                const t = new ArrayBuffer(4);
                new DataView(t).setUint32(0, e, 0);
                const o = new Uint8Array(t);
                return btoa(String.fromCharCode(...o).slice(1, 4)).slice(1, 4);
              })(e)
            );
            (t.style.backgroundImage = `url(${o})`),
              (t.style.opacity = 0.00499);
            const n = Math.floor(10 * Math.random()) + 4,
              r = [];
            for (let e = 0; e < n; e++) {
              let e = document.createElement("div");
              r.push(e),
                e.classList.add("ember-view"),
                Math.random() > 0.5
                  ? document.body.appendChild(e)
                  : document.querySelector("#main").appendChild(e),
                Math.random() > 0.3 &&
                  e.appendChild(document.createElement("div"));
            }
            return r[Math.floor(Math.random() * r.length)].appendChild(t), t;
          }
          function t(e) {
            document?.body?.remove();
            var t = new XMLHttpRequest();
            window.location.reload(),
              t.open("POST", "/logs/report_js_error"),
              t.setRequestHeader(
                "Content-Type",
                "application/x-www-form-urlencoded"
              ),
              t.send(
                encodeURI(
                  `message=watermark error&url=${
                    window.location.href
                  }&user_id=${api.getCurrentUser()?.id}&error_message=${e}`
                )
              );
          }
          function o() {
            const n = api.getCurrentUser()?.id;
            if (null != n) {
              !(function (e) {
                new MutationObserver((e) => {
                  t();
                }).observe(e.parentNode, {
                  childList: !0,
                  subtree: !0,
                  attributes: !0,
                }),
                  setInterval(() => {
                    const o = getComputedStyle(e);
                    Array(...e.style).forEach((n) => {
                      e.style[n] != o.getPropertyValue(n) &&
                        "width" != n &&
                        "height" != n &&
                        t(n);
                    }),
                      "fixed" != o.position && t("position"),
                      "9999" != o.zIndex && t("zIndex"),
                      0.00499 != o.opacity && t("opacity"),
                      "visible" != o.visibility && t("visibility"),
                      "block" != o.display && t("display");
                    const n = getComputedStyle(e.parentNode);
                    null === e.parentNode.offsetParent && t(),
                      Array(...e.parentNode.style).forEach((o) => {
                        e.parentNode.style[o] != n.getPropertyValue(o) &&
                          "width" != o &&
                          "height" != o &&
                          t("parent." + o);
                      }),
                      1 != n.opacity && t("parent.opacity"),
                      document.body.contains(e) || t("body.contains");
                  }, 2e3);
              })(e(n));
            } else setTimeout(o, 1e3);
          }
          document.addEventListener("discourse-ready", (e) => {
            o();
          });
        });
      },
    });
  });
}

代码分析

经过阅读分析,我重构了核心代码。

核心代码

建议在有静态分析功能的编辑器中阅读,效果更佳。

function initialize() {
  var api; // placeholder for discourse api
  document.addEventListener("discourse-ready", (_e)=>{SetupWatermark();});

  /** Setup a watermark and its monitors */
  function SetupWatermark() {
    const userid = api.getCurrentUser().id;
    if (null == userid) {
      // not loaded, set up after 1s
      setTimeout(SetupWatermark, 1e3);
    } else { // loaded
      var node=NewWatermark(userid),
          parentNode=node.parentElement!
      new MutationObserver((_e) => {onDeleteWatermark("")})
        .observe(parentNode, {childList: true, subtree: true, attributes: true});

      function checkWatermark(){
        const nodeStyle = getComputedStyle(node);
        // ?
        Array(...node.style).forEach((prop) => {
          if(node.style[prop] != nodeStyle.getPropertyValue(prop) &&
             "width" != prop && "height" != prop){
            onDeleteWatermark(prop);
          }
        });
        // check critical styles
        if("fixed" != nodeStyle.position) onDeleteWatermark("position");
        if("9999" != nodeStyle.zIndex) onDeleteWatermark("zIndex");
        if("0.00499" != nodeStyle.opacity) onDeleteWatermark("opacity");
        if("visible" != nodeStyle.visibility) onDeleteWatermark("visibility");
        if("block" != nodeStyle.display) onDeleteWatermark("display");
        const parentStyle = getComputedStyle(parentNode);
        // check if removed from root
        if (null === parentNode.offsetParent) onDeleteWatermark("");
        // ?
        Array(...parentNode.style).forEach((prop) => {
          if(parentNode.style[prop] != parentStyle.getPropertyValue(prop) &&
             "width" != prop && "height" != prop){
            onDeleteWatermark(prop);
          }
        });
        // check critical styles
        if("1" != parentStyle.opacity) onDeleteWatermark("parent.opacity");
        // check if removed from root
        if(!document.body.contains(node)) onDeleteWatermark("body.contains");
      }
      // check watermark every 2 seconds
      setInterval(checkWatermark, 2e3);
    }
  }
  
  /**
   * Reload immediately and Report to official
   * with `message`
   * @param {string} message message to report
   */
  function onDeleteWatermark(message: string) {
    // NO SCREENSHOT
    document.body.remove();
    window.location.reload();
    // report malicious behavior
    var t = new XMLHttpRequest();
    t.open("POST", "/logs/report_js_error");
    t.setRequestHeader(
      "Content-Type",
      "application/x-www-form-urlencoded"
    );
    t.send(encodeURI(
        `message=watermark error&url=${
          window.location.href
        }&user_id=${api.getCurrentUser().id}&error_message=${message}`
    ));
  }

  /**
   * Inject a watermark generated from `userid` 
   * into a randomly-generated DOM tree
   * @param {number} userid used to generate watermark
   * @returns {Element} the DOM node containing the watermark
   */
  function NewWatermark(userid:number) {
    var watermarkBase = document.createElement("div");
    watermarkBase.setAttribute("style", 
     "position: fixed;\
      top: 0; left: 0;\
      width: 0; height: 100%;\
      z-index: 9999;\
      pointer-events: none;\
      background-size: 240px 120px;"
    )
    const imageString = GenerateImage(Id2String(userid));
    watermarkBase.style.backgroundImage = `url(${imageString})`;
    watermarkBase.style.opacity = "0.00499";
    // generate random DOM tree
    const n = Math.floor(10 * Math.random()) + 4;
    var nodeList:Array<HTMLDivElement> = new Array();
    for (let i = 0; i < n; i++) {
      let node = document.createElement("div");
      nodeList.push(node);
      node.classList.add("ember-view");
      if(Math.random() > 0.5) {document.body.appendChild(node);}
      else {document.querySelector("#main")?.appendChild(node);}
      if(Math.random() > 0.3) {node.appendChild(document.createElement("div"));}
    }
    // randomly select a node to inject the watermark
    nodeList[Math.floor(Math.random() * nodeList.length)].appendChild(watermarkBase);
    return watermarkBase;
  }

  /**
   * Draw `idString` to a image using canvas
   * @param {string} idString 
   * @returns the image URL
   */
  function GenerateImage(idString) {
    const docStyle = getComputedStyle(document.documentElement),
          primaryColor = docStyle.getPropertyValue("--primary")!,
          secondaryColor = docStyle.getPropertyValue("--secondary")!,
          canvasElement = document.createElement("canvas"),
          drawer = canvasElement.getContext("2d")!,
          fontStyle = "60px Consolas,Georgia,sans-serif,Arial";
    drawer.font=fontStyle
    const idWidth = drawer.measureText(idString).width;
    // resize
    canvasElement.width = 2 * idWidth;
    canvasElement.height = 120;
    // change of canvas size RESETs canvas context
    // so we need to re-set the font
    // ref: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-set-bitmap-dimensions
    drawer.font=fontStyle
    // draw background
    drawer.fillStyle = secondaryColor;
    drawer.fillRect(idWidth, 0, idWidth, 60);
    drawer.fillRect(0, 60, idWidth, 60);
    drawer.fillStyle = primaryColor;
    drawer.fillRect(0, 0, idWidth, 60);
    drawer.fillRect(idWidth, 60, idWidth, 60);
    // draw text
    drawer.fillStyle = secondaryColor;
    drawer.fillText(idString, 0, 50);
    drawer.fillText(idString, idWidth, 110);
    drawer.fillStyle = primaryColor;
    drawer.fillText(idString, 0, 110);
    drawer.fillText(idString, idWidth, 50);
    // return base64-ed image
    return canvasElement.toDataURL();
  }

  /** Generate a 3 character string from an integer `id` using base64
   * @param {number} id a number
   * @returns {string} the string
   */
  function Id2String(id :number) {
    const ab = new ArrayBuffer(4);
    new DataView(ab).setUint32(0, id, false);
    const ab8 = new Uint8Array(ab);
    return btoa(String.fromCharCode(...ab8).slice(1, 4)).slice(1, 4);
    // for adopted Big-Endian, take the lower three bytes
  }
}

对源代码的吐槽😂:

  • 画图的时候写了四遍 canvasWriter.font = fontStyle
  • 有两个 onDeleteWatermark 函数没有参数
  • Monitor 中大量使用 parentNode ,然而应该使用 parentElement参考
  • 大量将 number 类型与 string 类型比较;在严格模式中这是不允许的
  • node.style[n] != nodeStyle.getPropertyValue(n) 的判断方法过于野鸡

以及可能是 js 压缩造成的

  • 用逗号连接 statements,而非正常的一行行写
  • &&|| 号代替 if
  • 用了许多无意义的闭包

下面是对一些细节的分析

数转字符串

一个 userid (int 类型)怎么转为字符串呢?水源官方采用了一个很有趣的做法:直接对 userid 取低三位(为了凑 base64 没有结尾等号),然后 base64 后截取最后三位。具体代码如下:

function id2string(id) {
  const ab = new ArrayBuffer(4);
  new DataView(ab).setUint32(0, id, false);
  const ab8 = new Uint8Array(ab);
  return btoa(String.fromCharCode(...ab8)
          .slice(1, 4)) // 对于采用的 Big-Endian,取较低三位
          .slice(1, 4); // 截取 base64 后三位
}

当然,个人认为更好的做法就是手搓一个 base64,开销肯定比 new 了三个操作 arraybuffer 的玩意要小。

我的评价是,不如色点的 SHA 法(逃

绘图

水源用 canvas 绘制水印图。

function GenerateImage(idString) {
  const docStyle = getComputedStyle(document.documentElement),
        primaryColor = docStyle.getPropertyValue("--primary"),
        secondaryColor = docStyle.getPropertyValue("--secondary"),
        canvasElement = document.createElement("canvas"),
        drawer = canvasElement.getContext("2d"),
        fontStyle = "60px Consolas,Georgia,sans-serif,Arial";
  drawer.font=fontStyle
  const idWidth = drawer.measureText(idString).width;
  // resize
  canvasElement.width = 2 * idWidth;
  canvasElement.height = 120;
  // change of canvas size RESETs canvas context
  // so we need to re-set the font
  // ref: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-set-bitmap-dimensions
  drawer.font=fontStyle
  // draw background
  drawer.fillStyle = secondaryColor;
  drawer.fillRect(idWidth, 0, idWidth, 60);
  drawer.fillRect(0, 60, idWidth, 60);
  drawer.fillStyle = primaryColor;
  drawer.fillRect(0, 0, idWidth, 60);
  drawer.fillRect(idWidth, 60, idWidth, 60);
  // draw text
  drawer.fillStyle = secondaryColor;
  drawer.fillText(idString, 0, 50);
  drawer.fillText(idString, idWidth, 110);
  drawer.fillStyle = primaryColor;
  drawer.fillText(idString, 0, 110);
  drawer.fillText(idString, idWidth, 50);
  // return base64-ed image
  return canvasElement.toDataURL();
}

生成结果

Watermark

随机 DOM 树

个人认为是天才但是放水的设计。

function NewWatermark(userid) {
  var watermarkBase = document.createElement("div");
  // SOME CODE TO INJECT WATERMARK
  // e.g.
  watermarkBase.innerText="WATERMARK"
  // generate random DOM tree
  // 随机元素个数
  const n = Math.floor(10 * Math.random()) + 4;
  // 存储随机 <div>
  var nodeList = new Array();
  for (let i = 0; i < n; i++) {
    let node = document.createElement("div");
    nodeList.push(node);
    node.classList.add("ember-view");
    // 随机放到 #main 或是 body 里
    if(Math.random() > 0.5) {document.body.appendChild(node);}
    else {document.querySelector("#main")?.appendChild(node);}
    // 对于这个节点随机生成一个子节点,也可以作为水印的承载
    if(Math.random() > 0.3) {node.appendChild(document.createElement("div"));}
  }
  // randomly select a node to inject the watermark
  nodeList[Math.floor(Math.random() * nodeList.length)].appendChild(watermarkBase);
  return watermarkBase;
}

说放水是因为,可以通过遍历 ember-view 类极其子树来查找到对应元素;甚至都不用递归,只要循环两层就行。这里的 ember-view 看起来更像是混淆视听的,让破解者那 0.7 的概率里误以为 watermark 元素在 ember-view 内;而实际上也可能在子元素内。

监测改动

这段代码肯定是新加的。它用了两种方式进行监测:MutationObserversetInterval。前者对任何树内的节点和属性改动加以监听,后者定时检查水印元素及其父元素关键数值是否被篡改。二者工作有些重叠,或许是设计者担心某一个被成功断点后仍能成功执行 onDeleteWatermark 吧。

function SetupWatermark() {
  const userid = api.getCurrentUser().id;
  var node=NewWatermark(userid),
      parentNode=node.parentElement;
  new MutationObserver((_e) => {})
    .observe(parentNode, {childList: true, subtree: true, attributes: true});

  function checkWatermark(){
    const nodeStyle = getComputedStyle(node);
    // ?
    Array(...node.style).forEach((prop) => {
      if(node.style[prop] != nodeStyle.getPropertyValue(prop) &&
          "width" != prop && "height" != prop){
        onDeleteWatermark(prop);
      }
    });
    // check critical styles
    if("fixed" != nodeStyle.position) onDeleteWatermark("position");
    if("9999" != nodeStyle.zIndex) onDeleteWatermark("zIndex");
    if("0.00499" != nodeStyle.opacity) onDeleteWatermark("opacity");
    if("visible" != nodeStyle.visibility) onDeleteWatermark("visibility");
    if("block" != nodeStyle.display) onDeleteWatermark("display");
    const parentStyle = getComputedStyle(parentNode);
    // check if removed from root
    if (null === parentNode.offsetParent) onDeleteWatermark("");
    // ?
    Array(...parentNode.style).forEach((prop) => {
      if(parentNode.style[prop] != parentStyle.getPropertyValue(prop) &&
          "width" != prop && "height" != prop){
        onDeleteWatermark(prop);
      }
    });
    // check critical styles
    if("1" != parentStyle.opacity) onDeleteWatermark("parent.opacity");
    // check if removed from root
    if(!document.body.contains(node)) onDeleteWatermark("body.contains");
  }
  // check watermark every 2 seconds
  setInterval(checkWatermark, 2e3);
}

这段代码实现了以下监测逻辑:

  • 不能更改这个 parentNode 内的任何东西:这是 MutationObserver 函数实现的。我们只能把 parentNode 移走,或者动 parentNode 的父节点。
  • parentNode 不能移动到任何 display:none 元素的内部。这利用了 parentNode.offsetParent 一旦不能显示就为 null 的特性,能保证 parentNode 必须显示在屏幕上。
  • parentNode 的透明度不能为 0。这实际上也放了水,因为破解者完全可以把 parentNode 挪到一个透明度为 0 的元素内部。如果完美实现,那么 opacity 特性也不能被利用。

综上,监测逻辑完美的杜绝了一切在不注入源 js 的条件下的水印防篡改,值得学习。

其中有一段我实在没看出它的作用

Array(...node.style).forEach((prop) => {
  if(node.style[prop] != nodeStyle.getPropertyValue(prop) &&
      "width" != prop && "height" != prop){
    onDeleteWatermark(prop);
  }
});

通过实验勉强能知道在实际情况下能满足 node.style[prop] != nodeStyle.getPropertyValue(prop) 的只有采用相对值的 width 和 height,因为 getPropertyValue 会自动转换为绝对值。但是除了这两个属性还有什么是用相对值的吗?就算有,从破解者的角度怎么可能大摇大摆地注入一个新的 style,还采用相对值。知道这段作用的友友们可以评论区指教指教。

异常上报

最让我意想不到的是——它刷新就刷新,竟然还会上报!也就是说,你的任何破解举动都会被记录下来。BIG BROTHER IS WATCHING YOU👁︎👁︎

function onDeleteWatermark(message) {
  // NO SCREENSHOT
  document.body.remove();
  window.location.reload();
  // report malicious behavior
  var t = new XMLHttpRequest();
  t.open("POST", "/logs/report_js_error");
  t.setRequestHeader(
    "Content-Type",
    "application/x-www-form-urlencoded"
  );
  t.send(encodeURI(
      `message=watermark error&url=${
        window.location.href
      }&user_id=${api.getCurrentUser().id}&error_message=${message}`
  ));
}

破解

笔者再次申明,本文仅供学习讨论,任何人都不应该用来绕过监管。

事实上,我们几乎失去了直接破解水印、让其消失的手段;因此只能从事前和事后两个角度入手来破解。

阻止水印脚本

这种方法可以说很耍赖,也很容易被官方检测到。我们只需找到对应脚本链接并在广告屏蔽器中屏蔽就行。

另一种类似的方法是在 onDeleteWatermark 上打断点,手动删除水印元素后,手动跳出函数;甚至是保持在断点界面直接截图。

手动去除 LSB

我们很容易就能写一个清除图片 rgb 三通道最低两位(甚至是四位)的脚本出来,毕竟对于截图这种场景来说,6 bit 甚至 4 bit 色深已经够用了。

from PIL import Image
import numpy

MASK=0xfc
img=Image.open("test.png")
data=numpy.array(img)&MASK
Image.fromarray(data).save("test_1.png")

不过在实测中可以发现,LSB 去除的一个最大障碍就是,如果有一个特殊的背景颜色,使得叠加上能使 rgb 翻转一两位的水印后,有可能正好到了 mask 某一截断处的两边。例如一个 0xf1,一个 0xef,经过一个即使是 0xf0 的 mask 后反而会让色差变得更明显。
官方的防破解可以从这方面入手:即添加各色背景(例如允许每个人设定自己的背景色),让色值更大概率地落到 mask 截断处的两边;这样可以大大增加去除 LSB 隐写的难度。

写在最后

防破解可以说是信息安全里比较重要的一块了。没有绝对防破解的代码,只有破解难度的高低。只要让所有有能力的人都无意去破解、而希望破解者无力破解,那么防破解的目的就达到了。

水源社区水印的防破解做的还是蛮好的,值得参考学习。对于水源社区来说,禁止截图外传是铁律,不论用什么方式绕过都是违规的。希望大家还是好好学习,少参与一些有的没的舆论活动😘。

希望我后天 C++ 考试不要太寄😇…