原文:https://www.igorkromin.net/index.php/2018/09/20/retrieving-data-from-hijacked-png-images-using-html-canvas-and-javascript/

简介


上一篇文章中,我描述了如何将任意文本数据(就这里来说,就是JSON)存储为PNG图像中的像素。为此,需要将输入数据从JavaScript的字符串转换为Uint8Array,然后在ImageData对象中使用三个通道(RGB)来存储字节,然后,将此图像绘制到画布中,最后保存为PNG文件。在本文中,我们将为读者详细介绍如何从这些图像中提取出原始数据。

在上一篇文章的最后,我们得到了如下所示的一幅图像:

该图像中存储一个序列化的JSON对象,该对象具有3700个索引属性,每个属性都包含一个字符串值“The quick brown fox jumps over the lazy dog”。

加载图像


我们可以通过创建Image对象并将其src属性设置为上一篇文章中的PNG图像来加载图像。在现实中,我们可以设置src属性或URL或甚至数据URL。在本例中,为了保持简单起见,这里将直接引用文件。

var img = new Image();
img.onload = function() {
    ...
};
img.src = 'image.png';

onload()回调函数就是所有解码代码之所在,但是,现在这里只有一个空函数,具体代码见下文。

将图像绘制到画布


加载图像后,我们需要访问其像素数据,这意味着必须首先将其渲染到屏外画布上面。为此,可以创建一个与图像具有相同大小的画布(假设它们都是正方形的),然后,将图像绘制到该2D背景中。

var imgSize = img.width;
var canvas = document.createElement('canvas');
canvas.width = canvas.height = imgSize;
var ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);

将像素转换为字节数组


从上一篇文章中可知,源图像最大为边长256像素的正方形,第一行像素用于表示保存实际数据的正方形的大小,这样的话,还剩下255行像素。这意味着最后一列像素也“报废”了,所以,我们最多使用一个边长为255像素的正方形来存储数据。当然,数据方块的实际大小是可以变化的,并且源图像中第一个像素的红色分量用于存储该方块的大小。

因此,我们只需读取第一个像素值的数据并抓取第一个字节,就能获悉数据的大小。

var headerData = ctx.getImageData(0, 0, 1, 1);
var dataSize = headerData.data[0];

一旦知道了数据的大小,就可以提取这个数据方阵了。请记住,这些数据是以RGBA像素的形式存放的,其中alpha值始终被设置为255(完全不透明),所以,我们需要创建一个足够大的Uint8Array数组来保存相应的RGB数据。

var imageData = ctx.getImageData(0, 1, dataSize, dataSize);
var paddedData = imageData.data;
var uint8array = new Uint8Array(paddedData.length / 4 * 3);

对于每4个字节数据,只需将前3字节的值复制到新数组中,并忽略第4个字节的值。这样做有两个原因:由于图像数据是作为Uint8ClampedArray数组返回的,这样能够得到一个标准的Uint8Array数组,同时,这样能够跳过alpha通道数据,从而转换为正确的数据类型!

var idx = 0;
for (var i = 0; i < paddedData.length - 1; i += 4) {
    var subArray = paddedData.subarray(i, i + 3);
    uint8array.set(subArray, idx);
    idx += 3;
}

现在,我们将得到一个数组,其中存放了我们所有的数据以及后面大量的零填充数据,当然,这些填充数据也是需要被剔除的。为此,我们需要找到数组中零填充数据段的结束位置:只需从数组末端开始遍历数组,直到我们遇到第一个非零字节为止。

var includeBytes = uint8array.length;
for (var i = uint8array.length - 1; i > 0; i--) {
    if (uint8array[i] == 0) {
        includeBytes--;
    }
    else {
        break;
    }
}

对字节数组进行解码


为了获得原始的String值,需要使用TextDecoder.decode()函数剔除掉零填充数据。

var data = uint8array.subarray(0, includeBytes);
var strData = (new TextDecoder('utf-8')).decode(data);

搞定!现在,strData变量中保存的就是编码到PNG图像中的原始字符串数据。在本文中,我们的原始数据是一个JSON对象,所以,这里可以使用JSON.parse()轻松将其转换为原来的对象。

正如我在上一篇文章中所说,这里的代码并非是最有效或最棒的,数据存储方式也不是最佳的,但它的确是行之有效的,至少能够满足我们自己的项目的需求。当然,我们还可以更进一步,在第一行存储一个特殊的像素序列,用于存放该图像可解码的指纹。如果一幅图像没有这个像素序列,解码代码就会拒绝对其进行相应的操作。

当然,本文介绍的技术的用途是灵活多样的,这就要看读者的了。最后,祝阅读愉快!

源链接

Hacking more

...