记一次分析某漫的图片

前言

记一次分析某漫的图片。

在前面,我们分析过了某漫的接口。

如果你未阅读过,可以先阅读这篇文章:记一次分析某漫接口密钥

正文

经过先前的分析之后,我们可以得出获取漫画内容的接口,这里要注意,这个接口返回的是一串 HTML 。

我们以车牌 416130 来作例子。

它的第一页的地址为 https://cdn-msp2.jmapiproxy2.cc/media/photos/416130/00001.webp

可以看到,图片是经过混淆的,无法直接阅读。

在返回的代码中,有这么一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 这个是使用 IntersectionObserver 的回调
// https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver
function handleIntersection(entries) {
entries.map((entry) => {
var canObj = $(entry.target).find("canvas");
var imgObj = $(entry.target).find("img");
if (!imgObj.hasClass("lazy-loaded")) {
return;
}
var doc_can = canObj.get(0);
let tmp_page = parseInt($(imgObj).attr("data-page"));
let remove_range =
parseInt(window.innerHeight / entry.boundingClientRect.height) +
4;
if (entry.isIntersecting) {
if (
canObj.length == 0 &&
imgObj.attr("src").indexOf("/blank.jpg") < 0
) {
scramble_image(imgObj[0], aid, scramble_id, false, speed); // 執行scramble_image函數來混亂圖像
}
removeOutsideCanvas(tmp_page, remove_range); // 刪除不在可視範圍內的canvas元素
}
});
}

这里有一个关键的函数 scramble_image ,但是如果我们全局搜索的话是搜索不到函数的定义的,它藏在了一些外部的 js 文件中。

这里我在百度上搜索到了一篇相关的文章:JS逆向某漫画webp图片乱序问题

阅读之后可以得知,这个函数在 jquery.photo-0.5.js 文件中定义。

该函数的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function scramble_image(img, aid, scramble_id, load, speed) {
if (!load) load = false;
if (!speed) speed = false;

if (
img.src.indexOf(".gif") > 0 ||
parseInt(aid) < parseInt(scramble_id) ||
speed == "1"
) {
if (img.style.display === "none") {
img.style.display = "block";
}
return;
}
if (load == true || img.complete == false) {
document.getElementById(img.id).onload = function () {
onImageLoaded(img);
};
} else {
onImageLoaded(img);
}
}

这里有一个重要的判断是 parseInt(aid) < parseInt(scramble_id)

回到调用语句 scramble_image(imgObj[0], aid, scramble_id, false, speed) ,通过搜索我们可以得知 aid416130 ,而 scramble_id220980

回到 scramble_image 内的逻辑,可以发现只有 aid > scramble_id 才会有额外的 onImageLoaded 调用。

我们可以猜测,并不是所有的漫画都是混淆的,只有 220980 之后的才是混淆的。

为了验证这个猜想,我们找一本 220980 之前的,比如 101005

它的第一页为 https://cdn-msp2.jmapiproxy1.cc/media/photos/101005/00001.webp

可以看到并没有经过混淆。

现在我们的需要分析的就是 onImageLoaded 这个函数了,它的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function onImageLoaded(img) {
var canvas;
if (img.nextElementSibling == null) {
canvas = document.createElement("canvas");
img.after(canvas);
} else {
canvas = document.getElementById(img.id).nextElementSibling;
}

var ctx = canvas.getContext("2d");
var s_w = img.width;
var w = img.naturalWidth;
var h = img.naturalHeight;
canvas.width = w;
canvas.height = h;

if (s_w > img.parentNode.offsetWidth || s_w == 0) {
s_w = img.parentNode.offsetWidth;
}
canvas.style.width = s_w + "px";
canvas.style.display = "block";

var page = document.getElementById(img.id).parentNode;
page = page.id.split(".");
page = page[0];

var num = get_num(window.btoa(aid), window.btoa(page));
var remainder = parseInt(h % num);
var copyW = w;

for (var i = 0; i < num; i++) {
var copyH = Math.floor(h / num);
var py = copyH * i;
var y = h - copyH * (i + 1) - remainder;

if (i == 0) {
copyH = copyH + remainder;
} else {
py = py + remainder;
}

ctx.drawImage(img, 0, y, copyW, copyH, 0, py, copyW, copyH);
}

$(img).addClass("hide");
}

从整体的逻辑看,它就是通过将 webp 重新绘制到一个 canvas 上,然后隐藏原来的 webp 。

其中核心的代码段为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var num = get_num(window.btoa(aid), window.btoa(page));
var remainder = parseInt(h % num);
var copyW = w;

for (var i = 0; i < num; i++) {
var copyH = Math.floor(h / num);
var py = copyH * i;
var y = h - copyH * (i + 1) - remainder;

if (i == 0) {
copyH = copyH + remainder;
} else {
py = py + remainder;
}

ctx.drawImage(img, 0, y, copyW, copyH, 0, py, copyW, copyH);
}

这里 aid 为车牌号, page 为当前的页码,即如果第一页地址为 https://cdn-msp2.jmapiproxy1.cc/media/photos/101005/00001.webp ,那么 page 就会为 00001

这里先是通过 get_num 获取了一个 num ,接着在循环内执行 num 次重绘逻辑,所以可以确定,这个 num 其实确定了混淆的图片被切成了多少块。

get_num 的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function get_num(aid, page) {
aid = window.atob(aid);
page = window.atob(page);

var num = 10;
var key = aid + page;
key = md5(key);
key = key.substr(-1);
key = key.charCodeAt();
if (aid >= window.atob("MjY4ODUw") && aid <= window.atob("NDIxOTI1")) {
key = key % 10;
} else if (aid >= window.atob("NDIxOTI2")) {
key = key % 8;
}
switch (key) {
case 0:
num = 2;
break;
case 1:
num = 4;
break;
case 2:
num = 6;
break;
case 3:
num = 8;
break;
case 4:
num = 10;
break;
case 5:
num = 12;
break;
case 6:
num = 14;
break;
case 7:
num = 16;
break;
case 8:
num = 18;
break;
case 9:
num = 20;
break;
}

return num;
}

这里的逻辑是根据传入的车牌号和 page 生成一个 key ,接着使用这个 key 来生成一个所需要返回的 num

我们按 416130 和第一页算,它的步骤如下:

  • 41613000001 拼接,得到 41613000001
  • 41613000001 进行 md5 操作,得到 2f7bd47844c13bd7fe289326dc1dd7c7
  • 取 md5 的最后一个字符串,拿到它的 charCode ( UTF-16 码元,其值介于 065535 之间)。
  • 对车牌号进行判断:
    • 车牌号在 268850window.atob("MjY4ODUw") )和 421925window.atob("NDIxOTI1") )之间的话,将上一步的值对 10 取余。
    • 车牌号大于 421925window.atob("NDIxOTI1") )的话,将上一步的值对 8 取余。
  • 对最终的 key 进行判断,返回对应的 num

回到 onImageLoaded ,前面我们说过 num 决定了图片被切割的数量, remainder 存放的是除不尽所剩的余量。比如如果一张图的高度是 1000 ,此时 num3 ,那么 remainder1

copyW 存放了图片的高度,从混淆后的图片可知,图片是切成一行一行的,所以还原前后的宽度都是一样的。

接下来就是最重要的一段代码了,也就是这个循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
for (var i = 0; i < num; i++) {
var copyH = Math.floor(h / num);
var py = copyH * i;
var y = h - copyH * (i + 1) - remainder;

if (i == 0) {
copyH = copyH + remainder;
} else {
py = py + remainder;
}

ctx.drawImage(img, 0, y, copyW, copyH, 0, py, copyW, copyH);
}

这里我们首先要明白 drawImage 这几个参数的意思,用 mdn 的图我觉得很合适:

第一个参数是画布,接着四个参数分别是源图像的左上角的 xy 坐标,以及绘制的长和宽,后四个参数为目标图像的左上角的 xy 坐标,以及绘制的长和宽。

前面我们说过,图像只在一个方向上混淆,所以可以看到,这里第二个参数(源图像 x) 和 第五个参数(目标图像 x)相同,源图的宽高( copyWcopyH )和目标的宽高( copyWcopyH )也是一样的。

这里面最重要的就是一个变量,即 y , 由于我们需要复原图像,所以 py 是从上往下绘制的,这点应该不难搞懂,我们需要明白的是,是源图的哪个部分绘制到了对应的位置上。

可以从源码得知 y 的计算为: y = h - copyH * (i + 1) - remainder

这里为了容易理解,我们可以假设此时的 remainder 为 0 ,前面我们说过, remainder 为余量,因为并不能保证图片的宽高和生成的 num 能除尽。

此时我们假设图片的高为 900num9 ,此时 remainder0 ,我们可以省略,即 y = h - copyH * (i + 1) 。此时我们可以手动计算下每次循环:

  • i = 0 (第一次循环),此时 y900 - 100 * (0 + 1) ,即 800
  • i = 1 (第二次循环),此时 y900 - 100 * (1 + 1) ,即 700
  • i = 2 (第二次循环),此时 y900 - 100 * (2 + 1) ,即 600

聪明的你应该明白了,其实就是从底部开始往目标位置上绘制,我们可以用一个简单的图示来表示:

也就是说,混淆的图片就是将源图切割成 num 块后,倒叙拼接起来而已。

最后,我们需要回过头来看一下 remainder ,在循环中,有这样一段条件逻辑:

1
2
3
4
5
if (i == 0) {
copyH = copyH + remainder;
} else {
py = py + remainder;
}

i = 0 ,也就是第一次绘制中,将 copyH 加上了余量,这意味着源图的最后一块是加上了 remainder 的,在我们前面的图例中,也就是第九块。而当 i > 0 时,由于目标图像第一块(源图第九块)是多绘制了 remainder 高度的,所以每一块都需要往下偏移 remainder ,这样可以确保不会覆盖到第一块的内容。

至此,我们基本完成了对该图像的还原,最后,贴一份重构后的 ts 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import CryptoJS from "crypto-js";

// 是否需要解混淆
export const needDecode = (comicId: number): boolean => {
return comicId > 220980;
};

// 得到一个加载了混淆图片的 img 元素
const getLoadedImage = async (src: string) => {
const img = document.createElement("img");
// 允许跨域
img.setAttribute("crossOrigin", "anonymous");
return new Promise<HTMLImageElement>((resolve, reject) => {
img.addEventListener("load", () => {
resolve(img);
});
img.addEventListener("error", (e) => {
reject(e);
});
img.src = src;
});
};

// 得到一个 num ,这里我们改为获取一个 seed 种子。
const seedMap = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20];
const getSeed = (comicIdStr: string, pageStr: string) => {
const key = comicIdStr + pageStr;
const keyMd5 = CryptoJS.MD5(key).toString();
let charCodeOfLastChar = keyMd5[keyMd5.length - 1].charCodeAt(0);
// window.atob("MjY4ODUw")
const left = "268850";
// window.atob("NDIxOTI1")
const right = "421925";
if (comicIdStr >= left && comicIdStr <= right) {
charCodeOfLastChar = charCodeOfLastChar % 10;
} else if (comicIdStr >= right) {
charCodeOfLastChar = charCodeOfLastChar % 8;
}
return seedMap[charCodeOfLastChar] ?? 10; // 默认 seed
};

// 解混淆核心逻辑
export const decodeImage = async (src: string, comicId: number) => {
const page = src.substring(src.lastIndexOf("/") + 1, src.lastIndexOf("."));
const img = await getLoadedImage(src);
const { naturalHeight, naturalWidth } = img;
const canvas = document.createElement("canvas");
canvas.width = naturalWidth;
canvas.height = naturalHeight;
const ctx = canvas.getContext("2d");
const seed = getSeed(comicId + "", page);
const remainder = naturalHeight % seed;
for (let i = 0; i < seed; i++) {
let height = Math.floor(naturalHeight / seed);
let dy = height * i;
const sy = naturalHeight - height * (i + 1) - remainder;
if (i == 0) {
height = height + remainder;
} else {
dy = dy + remainder;
}
ctx?.drawImage(
img,
// 源图位置
0,
sy, // source Y
naturalWidth,
height,
// 目标位置
0,
dy, // dest Y
naturalWidth,
height,
);
}
// 通过 URL.createObjectURL 来将 blob 生成一个 url 。
return new Promise<string>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
const file = new File([blob], page + ".webp", {
type: "image/webp",
});
resolve(URL.createObjectURL(file));
} else {
reject("canvas not output a blob by invoking 'toBlob' method.");
}
},
"image/webp",
1,
);
});
};

调用方法为 await decodeImage("https://cdn-msp2.jmapiproxy2.cc/media/photos/416130/00001.webp", 416130) ,之后就能得到一个 src 路径,访问即可得到解混淆后的图片。

后记

本文仅用于教育、学习和研究目的,旨在帮助开发者和用户理解应用程序的工作原理。本文作者与原始应用程序的开发者、公司或组织无关。所有涉及的代码或技术分析均为个人研究成果,并未用于商业用途或恶意活动。请勿将本文用于任何违反法律或侵犯原开发者权利的活动。本文作者不对他人使用本文内容产生的任何法律或财务后果承担责任。

最近也在写一个某漫的 win 客户端,基于 electron + react ,正好抽了点时间来写下这篇帖子。

这次也是明白了为啥有时候在 app 进入详情页的时候会短暂出现一幅混淆的图片了…