17
头图

前言

哈喽大家好~我是荣顶,马上中秋节啦 ~ 先祝大家中秋节快乐,阖家团圆,幸福安康~

这次之所以会出一期版权保护的内容,是因为前段时间有群友说他的文章被盗了,于是冲着对版权保护方法的好奇,就有了这篇文章 ~

版权保护方法有很多:
这次文章主要和大家讲讲如何在文本与图片中隐藏自己的版权信息(下一篇文章会给大家详细聊聊抠不掉的水印元素)

本文参与了中秋活动,所以文中的例子主要围绕着中秋的主题进行讲解,大家点赞支持一下 ~ 谢谢 ~ 🥰

看完本文,你将学会 👇

  • 文本隐写:通过某种方法竟然可以在字符串中读取和修改隐藏信息"我是荣顶"⇄"我是[前埔寨]荣顶",点这里体验

  • 图片隐写:在图片中添加隐藏的图文(还能往里面写文件!!!)点这里体验

文本隐写

首先我们需要了解一下,什么是隐写术(Steganography)

隐写术:将信息隐藏在多种载体中,如:视频、硬盘和图像,将需要隐藏的信息通过特殊的方式嵌入到载体中,而又不损害载体原来信息的表达。旨在保护需要隐藏的信息不被他人识别。

当然信息隐蔽技术肯定不止隐写术,大概有:1)隐写术、2)数字水印、3)隐蔽信道、4)阀下信道、5)匿名信道 ...

实现原理

通过将字符串中每个字符转换为只有 1 和 0 的表示,然后通过零宽字符表示 0 和 1,就能实现通过零宽字符来表示一串看不见的字符串

首先我们了解一下什么是零宽字符?

顾名思义: 就是字节宽度为 0 的特殊字符。🙄 这解释...那我走?

😀 零宽字符: 是一种不可打印的 Unicode 字符, 在浏览器等环境不可见, 但是真是存在, 获取字符串长度时也会占位置, 表示某一种控制功能的字符.

零宽字符主要有以下六种:

  • 零宽度空格符 (zero-width space) U+200B : 用于较长单词的换行分隔
  • 零宽度非断空格符 (zero width no-break space) U+FEFF : 用于阻止特定位置的换行分隔
  • 零宽度连字符 (zero-width joiner) U+200D : 用于阿拉伯文与印度语系等文字中,使不会发生连字的字符间产生连字效果
  • 零宽度断字符 (zero-width non-joiner) U+200C : 用于阿拉伯文,德文,印度语系等文字中,阻止会发生连字的字符间的连字效果
  • 左至右符 (left-to-right mark) U+200E : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为左至右
  • 右至左符 (right-to-left mark) U+200F : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为右至左

考虑到 Unicode 中有所谓的Surrogate Pair的情况,所以这里我们处理字符串的核心方法是codePointAtfromCodePoint

Surrogate Pair:是 UTF-16 中用于扩展字符而使用的编码方式,是一种采用四个字节(两个 UTF-16 编码)来表示一个字符。

Unicode 编码单元(code points)的范围从 0 到 1,114,111。开头的 128 个 Unicode 编码单元和 ASCII 字符编码一样。
如果指定的 index 小于 0 或大于字符串的长度,则 charCodeAt 返回 NaN

例如:汉字“𠮷”(非"吉")的码点是 0x20BB7,而 UTF-16 编码为 0xD842 0xDFB7(十进制为 55362 57271),需要 4 个字节储存。
对于这种 4 个字节的字符,JavaScript 不能正确处理,字符串长度会误判为 2,"𠮷".length // 2 而且 charAt()方法无法读取整个字符,charCodeAt()方法只能分别返回前两个字节和后两个字节的值。
ES6 提供了 codePointAt()方法,能够正确处理 4 个字节储存的字符,返回一个字符的码点。

//一个字符由两个字节还是由四个字节组成的最简单方法。
"我".codePointAt(0) > 0xFFFF;//false
"A".codePointAt(0) > 0xFFFF;//false
"3".codePointAt(0) > 0xFFFF;//false

"𠮷".codePointAt(0) > 0xFFFF;//true
console.log(String.fromCodePoint(0x20bb7)); // "𠮷"
//或者十进制
console.log(String.fromCodePoint(134071)); // "𠮷"
//也可以多个参数
console.log(String.fromCodePoint(25105,29233,0x4f60)); // "我爱你"

String.fromCodePoint 方法是 ES6 新增加的特性,ES6 提供了 String.fromCodePoint()方法,可以识别大于 0xFFFF 的字符,弥补了 String.fromCharCode()方法的不足。一些老的浏览器可能还不支持。可以通过使用这段 polyfill 代码来保证浏览器的支持

当然,如果不需要处理复杂的字符,你也可以用charCodeAtfromCharCode两个方法来对字符进行处理

好了,铺垫完了,开干~

先说加密

我们将文本通过codePointAt() 方法得出每个字符的Unicode编码点值,然后将它们转为2进制,每个字符间通过空格分隔开,然后我们用零宽字符&#8203来表示1,用零宽字符&#8204来表示0,用零宽字符&#8205来表示空格,这样就可以得到一段完全用零宽字符表示的看不见的字符串

多了不说,少了不唠 ~ 先看代码!

  // 字符串转零宽字符串
  function encodeStr(val = "隐藏的文字") {
      return (
          val
              .split("")
              .map((char) => char.codePointAt(0).toString(2))
              .join(" ")
              .split("")
              .map((binaryNum) => {
                  if (binaryNum === "1") {
                      return "​"; // 零宽空格符​
                  } else if (binaryNum === "0") {
                      return "‌"; // 零宽不连字符‌
                  } else {
                      return "‍"; //空格 -> 零宽连字符‍
                  }
              })
              .join("‎")
      );
  }
  console.log("str:",encodeStr(),"length:",encodeStr().length)//大家可以把这段copy到控制台执行以下看看

这一块一开始return出去的都是直接写在双引号中的零宽字符,但是你们复制到浏览器中估计也不方便看,反正它是看不到的,平台要是给我过滤了,那更尴尬

想在 vscode或者atom 中看到代码里是否有零宽字符,可以下载Highlight Bad Chars插件来实现零宽字符的高亮(如上图所示)

为了方便大家容易区分空字符串与零宽字符,这里我把代码改了一下(这里是用 vue 做的,以后我的所有例子都会在这个仓库下当然它也有线上的体验地址,欢迎大家来指点~)


// 字符串转零宽字符串
encodeStr() {
    this.cipherText = this.text.split("");
    //在字符串中的随机一个位置插入加密文本
    this.cipherText.splice(
        parseInt(Math.random() * (this.text.length + 1)),
        0,
        //加密的文本
        this.hiddenText
            .split("")
            //['荣', '顶' ]
            .map((char) => char.codePointAt(0).toString(2))
            // ['1000001101100011','1001100001110110']
            .join(" ")
            //"1000001101100011 1001100001110110"
            .split("")
            /* [ '1', '0', '0', '0',  '0', '0', '1', '1', '0', '1', '1', '0', '0',  '0', '1', '1', ' ',
                '1', '0', '0', '1', '1',  '0', '0', '0', '0', '1', '1', '1', '0', '1',  '1', '0'] */
            .map((binaryNum) => {
                if (binaryNum === "1") {
                    return String.fromCharCode(8203); // 零宽空格符​
                } else if (binaryNum === "0") {
                    return String.fromCharCode(8204); // 零宽不连字符‌
                } else {
                    return String.fromCharCode(8205); //空格 -> 零宽连字符‍
                }
            })
            //对上面所有的数组元素进行处理,生成一个新的数组['​', '​', '‌'......]其中每一个元素都是零宽字符,分别代表0和1以及
            .join(String.fromCharCode(8206))
        // 用左至右符‎来把上面的数组相连成一个零宽字符串=>"‎​‎‌‎‌"
    );
    this.cipherText = this.cipherText.join("");
    console.log(this.cipherText, "cipherText");
}

其中随机混入零宽字符串主要是这段伪代码

let str = "qwe12345789".split("");
//在字符串中的随机一个位置插入加密文本
str.splice(parseInt(Math.random()*(str.length+1)),0,"加密文本").join("")

加密后的文本通过 trim 或者通过网络发送都是没问题的

"中秋节快乐​‎​‎‌‎​‎​‎‌‎‌‎‌‎‌‎​‎​‎​‎‌‎‌‎‌‎‍‎​‎​‎‌‎‌‎​‎‌‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎‌‎‍‎​‎‌‎‌‎‌‎‌‎​‎‌‎‍‎​‎​‎​‎‌‎​‎‌‎​‎‍‎​‎​‎‌‎‌‎​‎​‎​".trim().length//114

大家都了解如何加密后,我们再看以下如何对字符串进行解密

解密首先需要了解如何提取零宽度字符

"点赞鼓励~😀​‎​‎​‎‌‎‌‎​‎‌‎‌‎​‎‌‎‌‎​‎‌‎‌‎‌‎‍‎​‎​‎‌‎‌‎​‎​‎​‎‌‎​‎‌‎‌‎‌‎‌‎​‎​‎‍‎​‎‌‎‌‎​‎​‎​‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎​‎‍‎​‎​‎‌‎‌‎‌‎‌‎‌‎‌‎​‎​‎‌‎​‎​‎​‎​‎‍‎​‎​‎‌‎​‎​‎‌‎‌‎‌‎‌‎‌‎​‎​‎​‎​‎‌‎​‎‍‎​‎​‎‌‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎‌‎‌‎‌‎‌‎‌".replace(/[^\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");

解密的过程就是加密的反向操作,我们先提取文本中的零宽字符串,用 1来表示零宽字符&#8203,用0来表示零宽字符&#8204,用空格来表示零宽字符&#8205,这样就可以得到一段由 1 和 0 组成空格分隔的字符串,我们将每段 1/0 表示的字符串转成十进制后,通过String.fromCharCode方法将其转回为可以看得见的文字即可~

// 零宽字符转字符串
decodeStr() {
    if (!this.tempText) {
        this.decodeText = "";
        return;
    }
    let text = this.tempText.replace(/[\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");
    let hiddenText = this.tempText.replace(/[^\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");
    console.log(text, "text");
    console.log(hiddenText, "hiddenText");
    this.decodeText = hiddenText
        .split("‎") //不是空字符串,是 ‎
        .map((char) => {
            if (char === "​" /* 不是空字符串,是​ */) {
                return "1";
            } else if (char === "‌" /*  不是空字符串,是‌ */) {
                return "0";
            } else {
                /* 是‍时,用空格替换 */
                return " ";
            }
        })
        .join("")
        //转数组
        .split(" ")
        //根据指定的 Unicode 编码中的序号值来返回一个字符串。
        .map((binaryNum) => String.fromCharCode(parseInt(binaryNum, 2)))
        .join("");
    console.log(text + hiddenText);
},

演示一波

核心实现方法就是以上,现在我们来演示一下,大家看图前,首先可以把这小段文字复制到任何地方打印下(就 F12 控制台看最方便)console.log("中秋快乐123456​‎​‎​‎‌‎‌‎​‎‌‎‌‎​‎‌‎‌‎​‎‌‎‌‎‌‎‍‎​‎​‎‌‎‌‎​‎​‎​‎‌‎​‎‌‎‌‎‌‎‌‎​‎​‎‍‎​‎‌‎‌‎​‎​‎​‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎​‎‍‎​‎​‎‌‎‌‎‌‎‌‎‌‎‌‎​‎​‎‌‎​‎​‎​‎​‎‍‎​‎​‎‌‎​‎​‎‌‎‌‎‌‎‌‎‌‎​‎​‎​‎​‎‌‎​‎‍‎​‎​‎‌‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎‌‎‌‎‌‎‌‎‌".length)长度一定不是 10😀

这里的 demo 我已经上传至我的 github,你可以点击这里体验一下~

应用场景

  • 数据防爬
    将零宽度字符插入文本中,干扰关键字匹配。爬虫得到的带有零宽度字符的数据会影响他们的分析,但不会影响用户的阅读数据。
  • 隐藏文本信息
    将自定义组合的零宽度字符插入文本中,用户复制后会携带不可见信息,达到隐藏文本信息的作用。
  • 逃脱敏感词过滤
    你在游戏里骂人,你妈*肯定是发不出去的,但是你发你xxxxx妈yyyyy*就不一样了 😀,这个东西还是研究起来很有意思的。
  • 嵌入隐藏的代码还有很多等等等...

试想当在电子书籍 PDF 或者一些正版影视音乐作品中嵌入购买人的个人信息,那还有多少人敢直接传给别人看呢?

像下面这种明水印,加了和没加没啥区别,很容易就被别人扣掉了

而且还被别人更换了水印,打上了广告,啧啧啧...如果一本电子书里嵌入了大量的隐写内容,对于盗版的传播行为,其实是可以起到很好的追责作用的(各大出版商,电子书可以搞起来 😀 买电子书都有购买者的联系方式和个人信息的)

诸如像 toG 的项目中,有很多敏感内容都是有个人信息的明水印,和隐写的个人信息,传出去了就知道是谁传的 (至少对于不懂技术的人来说,隐写是真的很难被发现的)

预防零宽字符的植入

说了这么多如何对字符进行零宽字符的植入和提取,那么像掘金哪天不允许你们加这玩意在里面了,他们对所有的文本用正则这么搞一遍就没了

预防用户上传的文本中含有零宽字符,一般我们先做一次替换即可

str.replace(/[\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");

图片隐写

什么是图片隐写?
lsb 隐写很实用,算法简单,能存储的信息量也大,更何况是 CTF 比赛中的常客(起源于 1996 年 DEFCON 全球黑客大会)

实现原理

图片的隐写有很多方式,我这里只用最简单的方式 (LSB:二进制最低位,不是"老色 X"😅) 做演示,当然也是因为我菜 😥 数学不好,要不然我也可以用傅里叶变换通过处理图片的色波 (图像本质上就是各种色彩波的叠加 这个概念的具体解释可以看阮一峰老师的这篇文章 ) 来做,那样又帅又飘~害!

二进制最低位

什么是二进制最低位?就是二进制最后面一位

好了,多了不说少了不唠 ~ 我们一起看图说话 ~

每个二进制的值的最低位都可以表示一个 1bit 的数据,一个像素需要 RGBA,4 个值共 4 * 8=32 个 bit 所以至少需要八个像素 (32 个值) 的最低位才能表示一个像素的 RGBA 值 (这里的 2560000 / 4 = 640000 个像素可以用来储存 2560000 / 32 = 80000 个像素的 RGBA 值)

即:每 32 个值的二进制最低位可以用来表示一个标准像素的 RGBA 值(为方便理解,如下图所示 👇)

拿到需要隐藏的数据

这一步,我们可以隐藏图片,隐藏文字,也可以自己手绘一些东西在画布上
将我们已经在主画布上绘制或加载的图片转换为 URL ,通过创建一个临时小画布, 将主画布生成的 URL 通过drawImage的方式,缩小的绘制到临时小画布上

(这里为什么要这么做?还是因为上面提到的 主画布的 640000 个像素值的最低位只能储存 80000 个像素的 RGBA 值)

//将画布上的信息绘制到小画布上保存起来
saveHiddenImageData() {
    const tempCanvas = document.createElement("canvas");
    const tempCtx = tempCanvas.getContext("2d");
    //小画布的长宽=大画布的像素/8后再开平方
    //因为需要八个像素的最低位才可以表示一个小画布的像素的RGBA值
    tempCanvas.width = Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8));
    tempCanvas.height = Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8));
    var image = new Image();
    image.src = this.canvas.toDataURL("image/png");
    image.onload = () => {
        //绘制图像到临时的小画布
        tempCtx.drawImage(image, 0, 0, tempCanvas.width, tempCanvas.height);
        this.hiddenData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
        this.hiddenData.binaryList = Array.from(this.hiddenData.data, (color) => {
            color = color.toString(2).padStart(8, "0").split("");
            return color;
        });
        console.log(this.hiddenData, "hiddenData");
        this.$message({
            type: "success",
            message: "保存成功!请选择目标图片~",
        });
        this.canvas.clear();
    };
},

再拿目标图的数据

在拿目标图(你要把隐藏的数据隐写进的目标图)的数据时候,我们需要预先处理一下所有的颜色值

这一步很关键,可以看到,我上面的图,画布加载完目标图片时,我们可以通过getImageData方法读取到页面的所有像素值,我这里将所有像素的二进制最低位全部归零,即把所有的颜色值都处理成偶数/非奇非偶数(0)

我们通过操作每个值的最低位,用来储藏要保存的数据, 肉眼是无法看出你对最低位的更改的

//获取画布像素数据
getCanvasData() {
    this.targetData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    //将数字化为非奇数
    function evenNum(num) {
        num = num > 254 ? num - 1 : num;
        num = num % 2 == 1 ? num - 1 : num;
        return num;
    }
    //存一个二进制的数值表示
    this.targetData.binaryList = Array.from(this.targetData.data, (color, index) => {
        this.targetData.data[index] = evenNum(this.targetData.data[index]);
        color = evenNum(color).toString(2).padStart(8, "0").split("");
        return color;
    });
    console.log(this.targetData);
    this.loading = false;
},

写入隐藏的数据

到这里,我们已经拿到了隐藏的数据,和目标图像的数据,接下来我们需要做的就是将这 318,096 个颜色值写入到目标图片的 2560000 个颜色值的二进制最低位中,即可实现图片的隐写

再次需要注意的是:通过最低位来做的话,我们隐写到图片中的数据是有限的,即 图片的整体像素 / 8 = 图片中可以隐藏的像素(个)
这里我们用的是800*800的画布,有640000个像素,其中可以隐藏 640000 / 8 = 80000个像素
所以我们隐藏的数据只能绘画到 Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8))这里是 282 的宽高的 canvas 中(79,524 个像素,318,096 个颜色值),这里求出的数只能向下取整,要不然会溢出,导致丢失隐藏的数据!

这里的操作就像是我们藏头诗一样,我们把数据藏在尾部

RGB 分量值的小量变动,是肉眼无法分辨的,不影响对图片的识别。你看不出来这个+1 的区别


多了不说少了不唠~先看代码

//将隐写的资源图片数据存到目标图片的二进制最低位中
drawHiddenData() {
    //将隐藏的数据的二进制全部放到一个数组里面
    let bigHiddenList = [];
    for (let i = 0; i < this.hiddenData.binaryList.length; i++) {
        bigHiddenList.push(...this.hiddenData.binaryList[i]);
    }
    console.log(bigHiddenList, "bigHiddenList");
    this.targetData.binaryList.forEach((item, index) => {
        bigHiddenList[index] && (item[7] = bigHiddenList[index]);
    });
    this.canvas.clear();
    this.targetData.data.forEach((item, index) => {
        this.targetData.data[index] = parseInt(
            this.targetData.binaryList[index].join(""),
            2
        );
    });

    const tempCanvas = document.createElement("canvas");
    tempCanvas.width = 800;
    tempCanvas.height = 800;
    let ctx = tempCanvas.getContext("2d");
    ctx.putImageData(this.targetData, 0, 0);
    fabric.Image.fromURL(tempCanvas.toDataURL(), (i) => {
        this.canvas.clear();
        this.canvas.add(i);
        this.canvas.renderAll();
    });
    this.$message({
        type: "success",
        message: "加密成功!",
    });
},

可以看到,这里已经将一张图片隐藏到了另一张图片中,(我们这里将一个月饼图隐写进了月亮图中)

解析加密后的图片

完成了图片加密后我们接下来需要做的是解析加密后的图片

首先我们选取本地图片后,将其渲染到画布上,在通过getImageData获得画布的像素数据,建立一个二进制的存储表示,后面将通过它的最低位取出隐藏在目标图中的图片

//获取画布像素数据
    getCanvasData() {
        this.targetData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
        //存一个二进制的数值表示
        this.targetData.binaryList = Array.from(this.targetData.data, (color, index) => {
            color = color.toString(2).padStart(8, "0").split("");
            return color;
        });
        console.log(this.targetData);
        this.loading = false;
    },

冲图片中抽取隐藏的颜色值

这里我们主要将主画布的二进制颜色值的最低位抽离出来,组装成隐藏图片的像素值数组,最后通过putImageData来绘制出隐藏其中的图片

这里非常需要注意的是: putImageData的第一个参数data的长度必须为两个边的乘积的4的倍数 否则就会报错
所以,这里我们取像素的时候,需要这么取Math.pow(Math.floor(Math.sqrt(2560000 / 32)), 2) * 4,由于我这里是 800 * 800的所以是2560000个值,你直接写成变量canvas.width * canvas.height *4即可

//解析图片
decryptImage() {
    const c = document.getElementById("decryptCanvas");
    const ctx = c.getContext("2d");

    let decryptImageData = [];

    for (let i = 0; i < this.targetData.binaryList.length; i += 8) {
        let tempColorData = [];
        for (let j = 0; j < 8; j++) {
            tempColorData.push(this.targetData.binaryList[i + j][7]);
        }
        decryptImageData.length < Math.pow(Math.floor(Math.sqrt(2560000 / 32)), 2) * 4 &&
            decryptImageData.push([...tempColorData]);
    }
    decryptImageData = Uint8ClampedArray.from(decryptImageData, (z) => {
        z = parseInt(z.join(""), 2);
        return z;
    });
    console.log(decryptImageData, "decryptImageData");
    //需要注意的是putImageData的data的长度必须为两个边的乘积的4的倍数
    ctx.putImageData(
        new ImageData(
            decryptImageData,
            Math.floor(Math.sqrt(2560000 / 8 / 4)),
            Math.floor(Math.sqrt(2560000 / 8 / 4))
        ),
        0,
        0
    );
},

经过这一步后,我们可以看到,就能够从主画布中提取出隐藏在其中的图片!!!

哇 ~ 是不是非常的有意思?

还需要注意的点 :LSB 方式的隐写图片只能存储为 PNG 或者 BMP 图片格式,并且不可以再用有损压缩(比如 JPEG),否则会丢失隐写的数据!

不要用隐写做违法犯罪的坏事!!!因为它是可以防(某墙)监控的,比如你把坏孩子图藏进了好孩子图里边,我去太坏了 😢

但是你身为程序员,你可以用它去给你心爱的宝贝表白撒,不失为一种浪漫的方式~

感兴趣的小伙伴还可以看一下这篇论文StegaStamp: Invisible Hyperlinks in Physical Photographs(隐写邮票:自然照片中嵌入不可见超链接),作者来自加州大学伯克利分校。他们的项目主页在这

以上是文本和图片的隐写相关内容,当然隐写的媒介不可能只局限于此,还有很多中,只要是计算机能用数字表达一切东西,按道理来说都是可以用来做隐写的,例如音频的隐写,以及视频的隐写

最后

隐写术是一门很深、应用很广泛的学问,这里讲的很泛,权当做抛砖引玉。图片和文字的隐写只是其中最简单的一部分,有兴趣的同学可以看一本叫《数据隐藏技术揭秘》的书。需要的小伙伴也可以加我,我发给你!
文中的例子已经放在了我的github中,当然你也可以通过这里体验一下

我是荣顶,很高兴能在这里和你一起变强!一起面向快乐编程! 😉

如果你也非常热爱前端相关技术!欢迎进入我的小密圈 ~ 里面都是大佬,带你飞! 🦄 扫描👇~


荣顶
570 声望2.5k 粉丝

​公​众​号​​‎​‎‌‎‌‎‌‎​: 前端超人