4

高深莫测的“隐写术”并不是黑客特工的专属 - 我用前端实现的隐写,非常有意思❗

 2 years ago
source link: https://segmentfault.com/a/1190000040686657
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

高深莫测的“隐写术”并不是黑客特工的专属 - 我用前端实现的隐写,非常有意思❗

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

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

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

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

看完本文,你将学会 👇

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

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

首先我们需要了解一下,什么是隐写术(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进制,每个字符间通过空格分隔开,然后我们用零宽字符​来表示1,用零宽字符‌来表示0,用零宽字符‍来表示空格,这样就可以得到一段完全用零宽字符表示的看不见的字符串

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

  // 字符串转零宽字符串
  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来表示零宽字符​,用0来表示零宽字符‌,用空格来表示零宽字符‍,这样就可以得到一段由 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中,当然你也可以通过这里体验一下

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK